diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 35410ca..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# 默认忽略的文件 -/shelf/ -/workspace.xml -# 基于编辑器的 HTTP 客户端请求 -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/CMakeLists.txt b/CMakeLists.txt index 98e0148..b4b65d8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 4.0) +cmake_minimum_required(VERSION 3.21) project(TactileIpc3D LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) @@ -7,6 +7,8 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src) find_package(Qt6 COMPONENTS @@ -15,7 +17,12 @@ find_package(Qt6 COMPONENTS Widgets QuickWidgets OpenGLWidgets + SerialPort REQUIRED + Quick + QuickControls2 + QuickLayouts + LinguistTools ) qt_standard_project_setup() @@ -23,17 +30,78 @@ qt_standard_project_setup() add_executable(TactileIpc3D main.cpp resources.qrc + src/translation_manager.h + src/translation_manager.cpp src/backend.h src/backend.cpp + src/data_backend.h + src/data_backend.cpp + src/data_frame.h src/glwidget.cpp src/glwidget.h + src/serial/serial_backend.h + src/serial/serial_backend.cpp + src/serial/serial_codec.h + src/serial/serial_decoder.h + src/serial/serial_format.h + src/serial/serial_manager.h + src/serial/serial_manager.cpp + src/serial/serial_queue.h + src/serial/serial_threads.h + src/serial/serial_threads.cpp + src/serial/serial_transport.h + src/serial/serial_qt_transport.h + src/serial/serial_qt_transport.cpp + src/serial/serial_types.h + src/serial/piezoresistive_a_protocol.h + src/serial/piezoresistive_a_protocol.cpp + src/ringbuffer.h + src/ringbuffer.cpp + src/sparkline_plotitem.h + src/sparkling_plotitem.cpp ) target_link_libraries(TactileIpc3D - Qt::Core - Qt::Gui - Qt::Widgets - Qt::QuickWidgets - Qt::OpenGLWidgets + Qt6::Core + Qt6::Gui + Qt6::Widgets + Qt6::QuickWidgets + Qt6::OpenGLWidgets + Qt6::SerialPort + Qt6::Quick + Qt6::QuickControls2 + Qt6::QuickLayouts +) + +set(TS_FILES + ${CMAKE_CURRENT_SOURCE_DIR}/i18n/app_zh_CN.ts + ${CMAKE_CURRENT_SOURCE_DIR}/i18n/app_en_US.ts +) + +file(GLOB_RECURSE I18N_CPP_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp +) + +file(GLOB_RECURSE I18N_QML_FILES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/qml/*.qml +) + +qt_add_translations(TactileIpc3D + TS_FILES ${TS_FILES} + SOURCES ${I18N_CPP_SOURCES} + QML_FILES ${I18N_QML_FILES} + QM_FILES_OUTPUT_VARIABLE QM_FILES +) + +foreach(qm_file IN LISTS QM_FILES) + get_filename_component(qm_name "${qm_file}" NAME) + set_source_files_properties("${qm_file}" PROPERTIES QT_RESOURCE_ALIAS "${qm_name}") +endforeach() + +qt_add_resources(TactileIpc3D i18n_resources + PREFIX "/i18n" + FILES ${QM_FILES} ) #if (WIN32 AND NOT DEFINED CMAKE_TOOLCHAIN_FILE) diff --git a/Prompt.md b/Prompt.md new file mode 100644 index 0000000..a27d1e3 --- /dev/null +++ b/Prompt.md @@ -0,0 +1,74 @@ +我在构建一款触觉传感器的工业上位机,主要用到了qml,opengl。我下面会说明我的需求。 + +## ui + +ui方面我希望使用1+3的模式,最上面是nav导航条,暂定有Title(左)和lightmode、language(右)。可以直观的展示名字、明暗和语言。ui风格使用qml中的material.green风格。 +下面是三栏,左边是panel,控制传感器的连接,opengl的展示和量程,规格等数据(这个我希望可以支持补充,我已经做好了一个LeftPanel.qml你可以参考)。中间是opengl对传感器的3D渲染。右边是一些可视化指标,折线图等。 + +## 整体设计 + +我希望可以对软件进行分层设计,有统一的后端(AppBackend)。这个AppBackend驱动其他功能组件。 + +首先是串口数据采集层,我希望可以抽象为codec,decodec,format,manager(类似ffmpeg)的设计来适配不同协议的传感器。传感器有从站模式和主站模式,你可以抽象为一个统一个config,在界面上配置参数,地址应该是一个十六进制的值(类似0x01)我觉得使用输入框比较好,如果是从站模式(也就是需要发送指令才能获取数据的),需要有一个参数配置采样频率(也就是多少时间发一次request)。我希望串口层是一个独立的层,他不负责业务功能,只提供基础的采集和slave功能。每个编解码器需要提供对应的接口(这里你需要预留一些接口让我来填充),比如获取版本号,获取传感器规格,request数据。解码器负责将接受到的一帧数据packet解码数据形成frame格式,frame中需要有pts(年月日时分妙毫秒202601011208xxx,24小时制)、功能码、数据(比如3*4规格就要有12个数据) + +为了避免采集功能造成ui卡顿,需要分别创建读取线程,解码线程,发送线程,类比ffmpeg的开发将frame和packet存储在一个队列里面,解码线程解码结束后将解码好的frame送入数据驱动层。如果是从站模式,那么request的编码和发送应该在发送线程进行编码和发送。我需要你完整的编写基于三个线程的编解码函数包括如何送入数据驱动层和导出。考虑到可能有多种编码器,我希望你在串口类中将几个函数抽象为function(比如request,parse等等),然后由对应的解码器类来提供实际的函数,类似find_decodec后的回调赋值。 + +现在来说数据驱动层,这里负责接受解码器的frame。存储到一个容器中,如果串口是打开的情况下,需要不断给ui输出最新数据,这里预留一个输出回调,我需要对给出数据的渲染进行一些自定义操作。数据驱动层还需要负责数据导出的功能,这里可以选择导出frame包(json格式)作为日志查看,也可以有导出csv的功能(pts,data1,data2)用来做数据分析。此外,我还需要数据层做到可以导入csv或者json数据来做数据回放,即导入数据后点击回放就重新展示导入的数据,当然这里数据显示也预留接口我来写这个展示回调。为了避免数据堆积,在新一次打开串口采集后或者导入新数据后就清掉上一次缓存或导入的数据(清空容器)。 + +关于opengl渲染,有一下内容需要实现。首先是我现在在opengl中使用dot着色器画出了很多点,但是这些都是画出来的不是实体控件,我现在捕获他们的点击事件来知道是哪个点并通过signal发出去怎么做呢,我预计是在RightPanel中的折线图中有数据显示麻(当然这个后面再说当前已经有了一个折线图placehold),但是起码需要有一个点击了某个点然后可以知道切发去信号。 + +关于数据的可视化,我这边计划使用opengl+一些折线图。这个后面说 + +下面我会提供“压阻A型”串口读,应答,协议结构。 + +- request + +| 字段 | data索引范围 | 长度(字节) | 示例值 / 说明 | 字节序 | +|------|--------------|------------|---------------|--------| +| 起始符0 | data[0] | 1 | 0x55 | 小端 | +| 起始符1 | data[1] | 1 | 0xAA | 小端 | +| 数据长度 | data[2-3] | 2 | 数据长度(2字节) | 小端 | +| 设备地址 | data[4] | 1 | 设备地址(1字节) | - | +| 预留 | data[5] | 1 | 0x00 | - | +| 0x80 + 功能码 | data[6] | 1 | 0x80 + 功能码 | - | +| 起始地址 | data[7-10] | 4 | 起始地址(4字节) | 小端 | +| 读取数据长度 | data[11-12] | 2 | 读取数据长度(2字节) | 小端 | +| CRC | data[13] | 1 | CRC 校验(1字节) | - | + +数据长度:从data[4]-data[12] + +- reply + +| 字段 | data索引范围 | 长度(字节) | 示例值 / 说明 | 字节序 | +|------|--------------|------------|---------------|--------| +| 起始符0 | data[0] | 1 | 0x55 | 小端 | +| 起始符1 | data[1] | 1 | 0xAA | 小端 | +| 数据长度 | data[2-3] | 2 | 数据长度(2字节) | 小端 | +| 设备地址 | data[4] | 1 | 设备地址(1字节) | - | +| 预留 | data[5] | 1 | 0x00 | - | +| 0x80 + 功能码 | data[6] | 1 | 0x80 + 功能码(示例:0xFB) | - | +| 起始地址 | data[7-10] | 4 | 起始地址(4字节) | 小端 | +| 返回字节数 | data[11-12] | 2 | 返回数据字节数 N(2字节) | 小端 | +| 状态 | data[13] | 1 | 0 = 成功;非0 = 失败 | - | +| 读取数据 | data[14 ... 13+N] | N | 读取到的数据(N字节,可变长度) | - | +| CRC | data[14+N] | 1 | CRC 校验(1字节) | - | + +数据长度:从data[4]-data[N+13] + +> ps:crc都是使用的CRC-8ITU + +我需要你根据协议内容编写可使用的采集、发送和解算和可视化代码。可视化部分流出TODO让我编写。Serial可以使用QT自带的QSerialPort或者serial库(我已经放在了项目目录下)。编写完成后在文档中画好好这些操作的流程图和对应的函数调用,你可以使用mermain。 + +## 文档 + +1. 用mermind绘制uml图,让我可以清晰的了解到项目的框架。 +2. 明确写出各个类的接口参数、作用,成员变量作用 +3. ui部分qml的层级接口,qml不同component的作用 + +注意: + +1. 一定要注意预留接口 +2. 层级分明,方便我后续迭代 +3. 在接口预留的位置标注`// TODO:待实现内容(具体写需要实现什么)` +4. 文档和TODO说明使用中 +5. 每一次的修改都需要在文档中有说明和体现 \ No newline at end of file diff --git a/README.md b/README.md index 22d30a2..92190f9 100644 --- a/README.md +++ b/README.md @@ -257,3 +257,79 @@ m_dotsProg->release(); - 想要更柔和的圆边:用 `smoothstep` 做 alpha 边缘 + 开启 blending - 想要真正的 3D 圆柱/球:就不能靠 discard 了,需要真正的几何或法线光照 +## Metrics ???? + +Metrics ?????????? payload?`DataFrame.data`??????? `metricsChanged` ??? + +- ?????? +- ????sqrt(mean(v^2)) +- ????mean(v) +- ??????? - ??? + +## Live Trend ?? + +Live Trend ????? payload ???sum of `DataFrame.data`???????????????? `src/data_backend.cpp` ? `updateMetrics_()` ???? `metricSum`??? `qml/content/RightPanel.qml` ??? `Backend.data.metricSum` ?????? + +## Live Trend ?????????? + +????????? `QImage` ?????????? DPI?devicePixelRatio??????? DPI ??????????????????????? y ??????????????????????? + +????? +- ???????? `devicePixelRatio` ??????? +- ???????????????????? +- ???????? y ???????????????? + +????????? + +```cpp +// src/sparkline_plotitem.h +private: + QSGTexture* getTextTexture(const QString& text, const QFont& font, QSize* outSize = nullptr); +``` + +```cpp +// src/sparkling_plotitem.cpp +QSGTexture* SparklinePlotItem::getTextTexture(const QString& text, const QFont& font, QSize* outSize) { + const qreal dpr = window() ? window()->devicePixelRatio() : 1.0; + const QString key = text + "|" + font.family() + "|" + QString::number(font.pixelSize()) + + "|" + QString::number(dpr, 'f', 2); + ... + const QSize pixelSize(qMax(1, qRound(sz.width() * dpr)), qMax(1, qRound(sz.height() * dpr))); + QImage img(pixelSize, QImage::Format_ARGB32_Premultiplied); + img.setDevicePixelRatio(dpr); + ... + m_textCache[key] = { tex, sz }; + if (outSize) + *outSize = sz; + return tex; +} + +QSGNode* SparklinePlotItem::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) { + const qreal dpr = window()->devicePixelRatio(); + auto alignToPixel = [dpr](float v) -> float { + return (dpr > 0.0) ? (std::round(v * dpr) / dpr) : v; + }; + ... + float py = alignToPixel(bottom - yn * plotH); + ... + QSize logicalSize; + QSGTexture* tex = getTextTexture(label, font, &logicalSize); + float ty = alignToPixel(py - logicalSize.height() / 2.0f); + tnode->setRect(QRectF(tx, ty, logicalSize.width(), logicalSize.height())); + ... +} +``` + +## Export Logic + +Export is driven by the Save dialog and DataBackend::exportHandler(): + +- QML triggers Backend.data.exportHandler(folder, filename, format, method) from qml/content/LeftPanel.qml. +- older is a local QUrl (e.g. ile:///C:/...), converted to a local path on the C++ side. +- Supported formats: csv, json. +- Supported methods: overwrite (default), ppend. + - CSV append: appends all current frames to the file. + - JSON append: loads existing array (if any), appends all current frames, and rewrites the file. +- xlsx and zip are not implemented yet and will log a warning. + +Relevant code: src/data_backend.cpp (exportHandler, uildCsv_, uildJson_), qml/content/SaveAsExportDialog.qml. diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..11f9291 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,10 @@ +mkdir -p deploy + +ldd $1 \ + | awk '/=> \// {print $3}' \ + | grep -vi 'windows' \ + | sort -u \ + | while read -r dll; do + echo "拷贝 $dll" + cp -u "$dll" . + done diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..d455ec3 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,534 @@ +# TactileIPC3D 架构说明 + +## UML (Mermaid) + +```mermaid +classDiagram + class AppBackend { + +bool lightMode + +string language + +bool connected + +SerialBackend* serial + +DataBackend* data + +setLightMode(bool) + +setLanguage(string) + } + + class SerialConfig { + +string portName + +int baudRate + +int dataBits + +int stopBits + +string parity + +uint8 deviceAddress + +DeviceMode mode + +int pollIntervalMs + } + + class SensorRequest { + +uint8 functionCode + +uint32 startAddress + +uint16 dataLength + } + + class SensorSpec { + +string model + +string version + +int rows + +int cols + +float pitch + +float dotRadius + +float rangeMin + +float rangeMax + } + + class SerialBackend { + +string portName + +int baudRate + +int pollIntervalMs + +int deviceAddress + +string mode + +int requestFunction + +int requestStartAddress + +int requestLength + +string protocol + +bool connected + +open() + +close() + +requestOnce() + +feedBytes(bytes) + +setTransport(transport) + } + class GLWidget { + +dotClicked(index, row, col, value) + } + + class SerialManager { + +registerProtocol(name, bundle) + +setActiveProtocol(name) + +activeBundle() + } + + class ISerialTransport { + <> + +open(config, error) + +close() + +writeBytes(data, error) + } + + class QtSerialTransport { + +open(config, error) + +close() + +writeBytes(data, error) + } + + class ISerialFormat { + <> + +tryParse(buffer, packet, error) + } + + class ISerialCodec { + <> + +buildRequest(config, request) + +buildGetVersionRequest(config) + +buildGetSpecRequest(config) + } + + class ISerialDecoder { + <> + +decodeFrame(packet, frame) + +decodeSpec(packet, spec) + } + + class PiezoresistiveAFormat + class PiezoresistiveACodec + class PiezoresistiveADecoder + + class DataBackend { + +ingestFrame(frame) + +clear() + +exportJson(path) + +exportCsv(path) + +importJson(path) + +importCsv(path) + +startPlayback(intervalMs) + +stopPlayback() + +setLiveRenderCallback(cb) + +setPlaybackRenderCallback(cb) + } + + class DataFrame { + +string pts + +uint8 functionCode + +float[] data + } + + class PacketQueue { + +push(packet) + +pop() + +clear() + +stop() + } + + class FrameQueue { + +push(frame) + +pop() + +clear() + +stop() + } + + class SerialReadThread { + +enqueueBytes(bytes) + +setParseFunc(func) + +start() + +stop() + } + + class SerialDecodeThread { + +setDecodeFunc(func) + +start() + +stop() + } + + class SerialSendWorker { + +setTransport(transport) + +setBuildRequestFunc(func) + +openTransport(config) + +closeTransport() + +requestOnce() + } + + AppBackend --> SerialBackend + AppBackend --> DataBackend + SerialBackend --> SerialConfig + SerialBackend --> SensorRequest + SerialBackend --> SensorSpec + SerialBackend --> SerialManager + SerialManager --> ISerialFormat + SerialManager --> ISerialCodec + SerialManager --> ISerialDecoder + ISerialFormat <|.. PiezoresistiveAFormat + ISerialCodec <|.. PiezoresistiveACodec + ISerialDecoder <|.. PiezoresistiveADecoder + ISerialTransport <|.. QtSerialTransport + SerialBackend --> DataFrame + DataBackend --> DataFrame + SerialBackend --> PacketQueue + SerialBackend --> FrameQueue + SerialBackend --> SerialReadThread + SerialBackend --> SerialDecodeThread + SerialBackend --> SerialSendWorker + SerialSendWorker --> ISerialTransport + SerialReadThread --> PacketQueue + SerialDecodeThread --> PacketQueue + SerialDecodeThread --> FrameQueue +``` + +说明:线程与队列已实现,队列溢出策略通过 TODO 注释预留后续扩展。 + +## 数据流/线程流程 (Mermaid) + +```mermaid +flowchart LR + UI[QML 按钮/配置] -->|open/close| SB[SerialBackend] + SB -->|openTransport| SW[SerialSendWorker] + SW -->|open| TP[QtSerialTransport] + SW -->|slave 模式轮询| REQ[buildRequest] + REQ -->|writeBytes| TP + TP -->|bytesReceived| RT[SerialReadThread] + RT -->|tryParse| PKT[PacketQueue] + PKT --> DT[SerialDecodeThread] + DT -->|decodeFrame| FR[FrameQueue] + FR --> SB + SB -->|drainFrames| DB[DataBackend] + DB -->|liveCallback| GL[GLWidget] + DB -->|TODO| RP[RightPanel 可视化] +``` + +## 时序/函数调用 (Mermaid) + +```mermaid +sequenceDiagram + participant UI as QML + participant SB as SerialBackend + participant SW as SerialSendWorker + participant TP as ISerialTransport + participant RT as SerialReadThread + participant DT as SerialDecodeThread + participant DB as DataBackend + participant GL as GLWidget + + UI->>SB: open() + SB->>SW: openTransport(config) + SW->>TP: open(config) + Note over SW: slave 模式启动轮询定时器 + SW->>SW: buildRequest() + SW->>TP: writeBytes(request) + TP-->>SW: bytesReceived(data) + SW-->>RT: enqueueBytes(data) + RT->>RT: tryParse(buffer) + RT-->>DT: PacketQueue.push(packet) + DT->>DT: decodeFrame(packet) + DT-->>SB: frameAvailable() + SB->>DB: ingestFrame(frame) + DB-->>GL: liveRenderCallback(frame) +``` + +## 配置流程 (Mermaid) + +```mermaid +flowchart TD + UI[QML 设置参数] -->|setPortName/ setBaudRate/ setDeviceAddress/ setMode/ setPollIntervalMs| SB[SerialBackend] + UI -->|setRequestFunction/ setRequestStartAddress/ setRequestLength| SB + UI -->|setProtocol| SB + SB -->|syncSendConfig_| SW[SerialSendWorker.setConfig] + SB -->|syncSendRequest_| SW2[SerialSendWorker.setRequest] + SB -->|updateProtocolBindings_| RT[SerialReadThread.setParseFunc] + SB -->|updateProtocolBindings_| DT[SerialDecodeThread.setDecodeFunc] + SB -->|updateProtocolBindings_| SW3[SerialSendWorker.setBuildRequestFunc] +``` + +## 配置接口与步骤说明 + +- 设置协议: + - `SerialBackend::setProtocol(name)`:切换协议后调用 `updateProtocolBindings_()`,更新 `ParseFunc` / `DecodeFunc` / `BuildRequestFunc`。 +- 设置串口配置(config): + - `SerialBackend::setPortName(name)` + - `SerialBackend::setBaudRate(rate)` + - `SerialBackend::setDeviceAddress(addr)`(0-255) + - `SerialBackend::setMode("master"/"slave")` + - `SerialBackend::setPollIntervalMs(intervalMs)`(从站模式轮询周期) + - 上述接口内部统一调用 `syncSendConfig_()`,将 `SerialConfig` 下发到 `SerialSendWorker::setConfig`。 +- 设置请求参数(request): + - `SerialBackend::setRequestFunction(func)` + - `SerialBackend::setRequestStartAddress(addr)` + - `SerialBackend::setRequestLength(len)` + - 上述接口内部调用 `syncSendRequest_()`,将 `SensorRequest` 下发到 `SerialSendWorker::setRequest`。 +- 设置解码/解析器: + - `SerialManager::registerProtocol(name, {codec, decoder, format})` + - `SerialBackend::setProtocol(name)` 触发 `updateProtocolBindings_()`: + - `SerialReadThread::setParseFunc(format->tryParse)` + - `SerialDecodeThread::setDecodeFunc(decoder->decodeFrame)` + - `SerialSendWorker::setBuildRequestFunc(codec->buildRequest)` +- 打开串口: + - `SerialBackend::open()` -> `SerialSendWorker::openTransport(config)`,成功后在从站模式启动轮询发送。 + +## 分层设计概述 + +- AppBackend:统一后端入口,驱动串口层与数据层,供 QML 直接绑定。 +- 串口采集层:`Transport + Format + Codec + Decoder + Manager` 分层,独立于业务逻辑。 +- 串口线程化:读取/解码/发送三线程 + Packet/Frame 队列,降低 UI 卡顿风险。 +- 数据驱动层:负责帧缓存、数据导入导出与回放,提供渲染回调。 +- UI 层:`NavBar + LeftPanel + OpenGL View + RightPanel` 的 1+3 布局。 + +## 类接口与成员说明(C++) + +### AppBackend (`src/backend.h`) +- 作用:统一后端入口,串联 `SerialBackend` 与 `DataBackend`。 +- 属性/接口: + - `lightMode` / `language` / `connected`:UI 基础状态。 + - `serial()` / `data()`:暴露子系统实例给 QML。 + - `setLightMode(bool)` / `setLanguage(string)`。 +- 成员变量: + - `m_serial`:串口采集层对象。 + - `m_data`:数据驱动层对象。 + - `m_lightMode` / `m_language`:全局 UI 状态。 +- 备注:串口连接成功后会清空历史数据缓存,避免旧数据残留。 + +### SerialBackend (`src/serial/serial_backend.h`) +- 作用:串口采集层的统一控制器,负责协议选择、三线程调度与数据分发。 +- 属性/接口: + - `portName` / `baudRate` / `deviceAddress` / `mode` / `pollIntervalMs`:串口与模式配置。 + - `requestFunction` / `requestStartAddress` / `requestLength`:请求参数。 + - `protocol` / `sensorModel` / `sensorGrid`:协议与传感器规格占位。 + - `open()` / `close()` / `requestOnce()` / `feedBytes(bytes)`。 + - `setTransport(transport)`:注入真实串口传输层。 + - `requestBuilt(bytes)`:输出原始请求帧,便于调试。 +- 成员变量: + - `m_config`:串口参数配置。 + - `m_request`:请求参数。 + - `m_spec`:传感器规格占位。 + - `m_manager`:协议注册与切换。 + - `m_packetQueue` / `m_frameQueue`:包/帧队列。 + - `m_readThread` / `m_decodeThread`:读取与解码线程。 + - `m_sendWorker` / `m_sendThread`:发送线程与 worker。 + - `m_frameCallback`:解码后数据回调。 +- 备注:通过 `ParseFunc` / `DecodeFunc` / `BuildRequestFunc` 绑定协议实现,模拟 “find_decodec 后回调赋值”。 + +### PacketQueue / FrameQueue (`src/serial/serial_queue.h`) +- 作用:线程安全的阻塞队列,用于 Packet/Frame 的跨线程传递。 +- 接口: + - `push(item)` / `pop(item)` / `tryPop(item)` / `clear()` / `stop()` / `reset()` + - `setMaxSize(size)`:配置队列容量。 +- 备注:队列溢出策略已通过 TODO 注释预留,后续可扩展为丢弃最新/阻塞等待。 + +### SerialReadThread (`src/serial/serial_threads.h`) +- 作用:读取线程,接收原始字节流并根据 `ParseFunc` 解析为 packet。 +- 接口: + - `enqueueBytes(bytes)`:注入串口字节流。 + - `setParseFunc(func)`:绑定协议解析函数。 + - `start()` / `stop()`:线程控制。 + +### SerialDecodeThread (`src/serial/serial_threads.h`) +- 作用:解码线程,从 `PacketQueue` 解码 packet 并写入 `FrameQueue`。 +- 接口: + - `setDecodeFunc(func)`:绑定协议解码函数。 + - `start()` / `stop()`:线程控制。 + +### SerialSendWorker (`src/serial/serial_threads.h`) +- 作用:发送线程 worker,负责请求编码与发送,并在从站模式下轮询。 +- 接口: + - `setTransport(transport)`:注入传输层实例。 + - `setBuildRequestFunc(func)`:绑定请求编码函数。 + - `openTransport(config)` / `closeTransport()`:串口打开/关闭。 + - `requestOnce()`:触发一次请求发送。 + +### SerialManager (`src/serial/serial_manager.h`) +- 作用:协议包管理器,注册 `codec/decoder/format` 组合。 +- 接口: + - `registerProtocol(name, bundle)`:注册协议。 + - `setActiveProtocol(name)`:切换协议。 + - `activeBundle()`:获取当前协议绑定。 +- 成员变量: + - `m_protocols`:协议字典。 + - `m_activeName`:当前协议名。 + +### ISerialTransport (`src/serial/serial_transport.h`) +- 作用:串口传输抽象层,屏蔽不同平台差异。 +- 接口: + - `open(config, error)` / `close()` / `writeBytes(data, error)`。 +- 备注:实际接收数据通过 `bytesReceived` 信号上报。 +- 实现: + - `QtSerialTransport`:基于 `QSerialPort` 的默认传输实现。 + - TODO:待实现内容(在传输层支持软/硬件流控配置) + +### ISerialFormat / ISerialCodec / ISerialDecoder +- 作用:协议拆分层,类比 FFmpeg 的 `format/codec/decoder`。 +- ISerialFormat 接口: + - `tryParse(buffer, packet, error)`:从字节流中提取完整包。 +- ISerialCodec 接口: + - `buildRequest(config, request)`:生成请求帧。 + - `buildGetVersionRequest(config)`:// TODO:待实现内容(构建获取版本号的请求帧) + - `buildGetSpecRequest(config)`:// TODO:待实现内容(构建获取传感器规格的请求帧) +- ISerialDecoder 接口: + - `decodeFrame(packet, frame)`:解码回复帧。 + - `decodeSpec(packet, spec)`:// TODO:待实现内容(解析规格回复帧并填充 SensorSpec) + +### Piezoresistive A 协议 (`src/serial/piezoresistive_a_protocol.*`) +- `PiezoresistiveAFormat`:包解析与 CRC-8/ITU 校验。 +- `PiezoresistiveACodec`:构建请求帧。 +- `PiezoresistiveADecoder`:解析回复帧为 `DataFrame`。 +- 备注:数据以小端 `uint16` 解析为 float,若协议变更可在此调整。 + +### DataBackend (`src/data_backend.h`) +- 作用:数据驱动层,负责帧缓存、导入导出与回放。 +- 接口: + - `ingestFrame(frame)`:实时采集数据入口。 + - `clear()`:清空缓存。 + - `exportJson(path)` / `exportCsv(path)`。 + - `importJson(path)` / `importCsv(path)`。 + - `startPlayback(intervalMs)` / `stopPlayback()`。 + - `setLiveRenderCallback(cb)` / `setPlaybackRenderCallback(cb)`:渲染回调占位。 +- 成员变量: + - `m_frames`:帧数据容器。 + - `m_playbackTimer` / `m_playbackIndex`:回放控制。 + +### GLWidget (`src/glwidget.h`) +- 作用:OpenGL 渲染窗口,显示传感器点阵与背景。 +- 信号: + - `dotClicked(index, row, col, value)`:鼠标点击某个点时发出索引与数据值。 +- 备注:拾取使用屏幕投影 + 半径阈值,便于 RightPanel 订阅点击事件做曲线展示。 + +### DataFrame (`src/data_frame.h`) +- 字段: + - `pts`:`yyyyMMddhhmmsszzz` 时间戳。 + - `functionCode`:功能码。 + - `data`:传感器数据。 + +### SerialConfig / SensorRequest / SensorSpec (`src/serial/serial_types.h`) +- `SerialConfig`:串口基础参数配置。 +- `SensorRequest`:请求参数(功能码、起始地址、读取长度)。 +- `SensorSpec`:传感器规格占位(型号/网格/量程等)。 + +## 协议结构说明(压阻 A 型) + +- Request 起始符:`0x55AA`(小端 -> `AA 55`)。 +- Reply 起始符:`0x55AA`(小端 -> `AA 55`)。 +- 数据长度:从 `data[4]` 到 payload 末尾(不含 CRC)。 +- CRC:CRC-8/ITU(多项式 0x07,初始值 0x00)。 + +## QML 层级与组件职责 + +### 总体布局(`qml/content/App.qml`) +- 顶部 `NavBar`:标题、连接状态、明暗切换、语言选择。 +- 中部三栏: + - 左侧 `LeftPanel`:串口连接、采样参数、规格与显示控制。 + - 中间 OpenGL 视图:由 C++ `GLWidget` 挂载显示 3D 传感器数据,并提供点点击信号。 + - 右侧 `RightPanel`:折线趋势、指标卡片、会话信息。 + +### NavBar(`qml/content/NavBar.qml`) +- Title:软件名称显示。 +- 连接指示:绿/红状态灯 + 文本。 +- `Switch`:Light/Dark 切换。 +- `ComboBox`:语言选择。 + +### LeftPanel(`qml/content/LeftPanel.qml`) +- 连接设置: + - COM 端口、波特率。 + - 模式选择(主站/从站)。 + - 设备地址输入(十六进制 `0x01` 形式)。 + - 采样周期(从站模式可用)。 +- 采样参数:功能码、起始地址、读取长度。 +- 传感器规格:协议名、型号、网格规格占位。 +- 显示控制:显示网络/坐标轴、回放与导出入口。 + +### RightPanel(`qml/content/RightPanel.qml`) +- Live Trend:折线趋势图示例。 +- Metrics:峰值、RMS、均值、Delta 等指标卡片。 +- Session:帧数、回放状态。 +- LiveTrendCard:封装 `SparklinePlot`(C++ QQuickItem)用于趋势绘制,数据接入留有 TODO。 + +## 预留扩展(实现建议) + +- 串口线程化已落地: + - `SerialReadThread` 读取字节流 -> `PacketQueue`。 + - `SerialDecodeThread` 解包/解码 -> `FrameQueue`。 + - `SerialSendWorker` 从站模式下定时发送请求。 +- 队列溢出策略:`SerialQueue` 中已预留 TODO 注释,可扩展为阻塞等待或丢弃最新。 +- 回调扩展: + - `DataBackend` 的渲染回调用于自定义 OpenGL/曲线刷新逻辑。 +- 可视化接入: + - `main.cpp` 与 `qml/content/RightPanel.qml` 已标注 // TODO:待实现内容(将实时帧推送到右侧曲线/指标)。 +- 协议扩展: + - `buildGetVersionRequest` / `buildGetSpecRequest` / `decodeSpec` 需实现,已保留 // TODO:待实现内容。 + + +## 数据导出进度 + +- 已完成 + - `DataBackend::exportJson(path)` / `exportCsv(path)` 已实现导出。 + - `DataBackend::importJson(path)` / `importCsv(path)` 已实现导入。 + - `qml/content/SaveAsExportDialog.qml` 提供路径、文件名、格式、方式选择,并通过 `saveTo(...)` 抛出导出参数。 + - 导出对话框以独立 `Window` 方式呈现(ApplicationModal),避免在 `QQuickWidget` 内被裁剪。 +- 待开发 + - 将 `saveTo` 与 `DataBackend` 导出接口打通,并补充失败提示。 + - 覆盖/追加/压缩(zip)策略与文件存在检测流程。 + - `xlsx` 导出实现与导出图标资源补齐(TODO)。 + +## TODO 汇总 + +- 代码/界面 + - `main.cpp:106` 将 frame 数据分发给右侧曲线/指标的 QML 接口。 + - `qml/content/RightPanel.qml:40` 用 DataBackend 输出的帧更新折线图/指标。 + - `src/serial/serial_queue.h:28` 指定队列溢出策略(丢弃最新/丢弃最旧/阻塞等待)。 + - `src/serial/serial_qt_transport.cpp:72` 根据 SerialConfig 扩展软件/硬件流控配置。 + - `src/serial/serial_codec.h:17` 构建获取版本号的请求帧。 + - `src/serial/serial_codec.h:19` 构建获取传感器规格的请求帧。 + - `src/serial/serial_decoder.h:18` 解析规格回复帧并填充 SensorSpec。 + - `src/serial/piezoresistive_a_protocol.cpp:126` 压阻 A 型版本号查询请求帧。 + - `src/serial/piezoresistive_a_protocol.cpp:132` 压阻 A 型规格查询请求帧。 + - `src/serial/piezoresistive_a_protocol.cpp:222` 解析压阻 A 型规格回复并写入 SensorSpec。 +- 文档/流程图标注 + - `docs/ARCHITECTURE.md:187` 队列溢出策略 TODO 预留说明。 + - `docs/ARCHITECTURE.md:205` 数据流图 RightPanel 可视化 TODO。 + - `docs/ARCHITECTURE.md:323` 队列溢出策略 TODO 备注。 + - `docs/ARCHITECTURE.md:363` 传输层软/硬件流控配置 TODO。 + - `docs/ARCHITECTURE.md:371` buildGetVersionRequest TODO。 + - `docs/ARCHITECTURE.md:372` buildGetSpecRequest TODO。 + - `docs/ARCHITECTURE.md:375` decodeSpec TODO。 + - `docs/ARCHITECTURE.md:449` LiveTrendCard 数据接入 TODO。 + - `docs/ARCHITECTURE.md:457` SerialQueue 溢出策略 TODO。 + - `docs/ARCHITECTURE.md:461` main.cpp/RightPanel.qml 实时帧推送 TODO。 + - `docs/ARCHITECTURE.md:463` buildGetVersion/buildGetSpec/decodeSpec TODO。 + - `docs/ARCHITECTURE.md:520` 更新记录提及可视化 TODO。 + - `docs/ARCHITECTURE.md:522` 更新记录提及队列溢出 TODO。 +- 测试/第三方 + - `test/onlygl/stb_image.h:1276` move stbi__convert_format to here. + - `test/onlygl/stb_image.h:1302` move stbi__convert_format16 to here. + - `test/onlygl/stb_image.h:1303` special case RGB-to-Y (and RGBA-to-YA) for 8-bit-to-16-bit case to keep more precision. + - `test/onlygl/stb_image.h:5898` tga_x_origin @TODO。 + - `test/onlygl/stb_image.h:5899` tga_y_origin @TODO。 +- 其他/说明 + - `Prompt.md:60` 可视化部分流出 TODO。 + - `Prompt.md:72` 接口预留位置标注 TODO。 + - `Prompt.md:73` 文档和 TODO 说明。 + - `serial/doc/Doxyfile:603` Doxygen TODO 列表说明。 + - `serial/doc/Doxyfile:607` GENERATE_TODOLIST 开关。 + +## 更新记录 + +- 2026-01-11:新增 `QtSerialTransport`(`QSerialPort` 传输实现)并设为默认传输;补齐点选拾取逻辑;新增数据流/时序图并补充可视化 TODO 说明。 +- 2026-01-11:补充串口配置流程图与配置接口说明(协议/参数/解码器绑定/打开流程)。 +- 2026-01-05:新增串口三线程流水线(读/解码/发送)与 Packet/Frame 队列,更新协议起始符说明,补充队列溢出 TODO 与线程组件文档。 +- 2026-01-05:CollapsiblePanel 组件改为跟随 `backend.lightMode` 切换暗色主题配色。 +- 2026-01-05:`QSplitter` 句柄配色跟随明暗模式并缩窄,消除 darkmode 下的白色缝隙。 +- 2026-01-05:`QQuickWidget` clearColor 跟随明暗模式,避免面板圆角处漏出白底。 +- 2026-01-05:`QQuickWidget` 在 `setSource` 后重设 `backend` 上下文属性,修复 QML 访问空指针报错。 +- 2026-01-05:`QQuickWidget` 改为通过 `engine()->rootContext()` 注入 `backend`,避免上下文被重建导致为空。 +- 2026-01-05:改为每个 `QQuickWidget` 引擎注入 `Backend` 上下文属性,避免多引擎使用单例导致的报错。 +- 2026-01-05:调整 `SerialSendWorker` 生命周期清理方式,避免跨线程 moveToThread 警告。 +- 2026-01-06:多个 `QQuickWidget` 共享同一个 `QQmlEngine`,统一注入 `Backend/backend` 上下文属性以稳定访问。 +- 2026-01-06:统一 QML 使用 `backend` 上下文属性名称,避免大写标识被当成类型解析导致取值为空。 +- 2026-01-06:为每个 `QQuickWidget` 的 `rootContext()` 重复绑定 `backend`,避免上下文被重建时丢失。 +- 2026-01-06:`QQuickWidget` 改用 `QQmlComponent + QQmlContext` 创建根对象并 `setContent`,确保 `backend` 上下文稳定注入。 +- 2026-01-07:`GLWidget` 增加点阵点击拾取信号 `dotClicked`,文档补充 OpenGL 交互说明。 diff --git a/i18n.qrc b/i18n.qrc new file mode 100644 index 0000000..53aed77 --- /dev/null +++ b/i18n.qrc @@ -0,0 +1,7 @@ + + + + i18n/app_en_US.ts + i18n/app_zh_CN.ts + + diff --git a/i18n/app_en_US.ts b/i18n/app_en_US.ts new file mode 100644 index 0000000..9e35756 --- /dev/null +++ b/i18n/app_en_US.ts @@ -0,0 +1,298 @@ + + + + + NavBar + + CONNECTED + CONNECTED + + + DISCONNECTED + DISCONNECTED + + + Light + Light + + + Dark + Dark + + + + ControlPanel + + Dark mode + Dark mode + + + Render + Render + + + Mode + Mode + + + Labels + Labels + + + Legend + Legend + + + Scale + Scale + + + Min + Min + + + Max + Max + + + + LabeledSlider + + Text + Text + + + + LeftPanel + + 连接设置 + Connection Settings + + + COM Port + COM Port + + + Baud + Baud + + + 模式 + Mode + + + 从站 + Slave + + + 主站 + Master + + + 设备地址 + Device Address + + + 采样周期 + Sample Interval + + + 连接 + Connect + + + 断开 + Disconnect + + + 采样参数 + Sampling + + + 功能码 + Function Code + + + 起始地址 + Start Address + + + 读取长度 + Read Length + + + 传感器规格 + Sensor Specs + + + 协议 + Protocol + + + 型号 + Model + + + 规格 + Spec + + + 重新识别 + Rescan + + + 显示控制 + Display + + + 显示网络 + Show Grid + + + 显示坐标轴 + Show Axes + + + 回放数据 + Playback Data + + + 导出数据 + Export Data + + + + RightPanel + + Payload Sum + Payload Sum + + + Live Trend + Live Trend + + + Metrics + Metrics + + + 峰值 + Peak + + + 均方根 + RMS + + + 平均值 + Average + + + 变化量 + Delta + + + Session + Session + + + Frames + Frames + + + Playback + Playback + + + Running + Running + + + Idle + Idle + + + + SaveAsExportDialog + + 导出数据 + Export Data + + + 在此位置中搜索 + Search in this location + + + 位置 + Locations + + + 此电脑 + This PC + + + 桌面 + Desktop + + + 文档 + Documents + + + 下载 + Downloads + + + 名称 + Name + + + 修改时间 + Modified + + + 文件名 + File name + + + 输入要保存的文件名 + Enter a file name + + + 文件类型 + File type + + + 导出方式 + Export mode + + + 覆盖导出(同名替换) + Overwrite (replace) + + + 追加导出(写入同一文件) + Append (same file) + + + 压缩导出(zip) + Compressed (zip) + + + 取消 + Cancel + + + 保存 + Save + + + 文件已存在 + File exists + + + 目标文件已存在,是否覆盖? + The file already exists. Overwrite? + + + diff --git a/i18n/app_zh_CN.ts b/i18n/app_zh_CN.ts new file mode 100644 index 0000000..e92fb0a --- /dev/null +++ b/i18n/app_zh_CN.ts @@ -0,0 +1,298 @@ + + + + + NavBar + + CONNECTED + 已连接 + + + DISCONNECTED + 未连接 + + + Light + 浅色 + + + Dark + 深色 + + + + ControlPanel + + Dark mode + 深色模式 + + + Render + 渲染 + + + Mode + 模式 + + + Labels + 标签 + + + Legend + 图例 + + + Scale + 范围 + + + Min + 最小值 + + + Max + 最大值 + + + + LabeledSlider + + Text + 文本 + + + + LeftPanel + + 连接设置 + 连接设置 + + + COM Port + 串口 + + + Baud + 波特率 + + + 模式 + 模式 + + + 从站 + 从站 + + + 主站 + 主站 + + + 设备地址 + 设备地址 + + + 采样周期 + 采样周期 + + + 连接 + 连接 + + + 断开 + 断开 + + + 采样参数 + 采样参数 + + + 功能码 + 功能码 + + + 起始地址 + 起始地址 + + + 读取长度 + 读取长度 + + + 传感器规格 + 传感器规格 + + + 协议 + 协议 + + + 型号 + 型号 + + + 规格 + 规格 + + + 重新识别 + 重新识别 + + + 显示控制 + 显示控制 + + + 显示网络 + 显示网络 + + + 显示坐标轴 + 显示坐标轴 + + + 回放数据 + 回放数据 + + + 导出数据 + 导出数据 + + + + RightPanel + + Payload Sum + 载荷总和 + + + Live Trend + 实时趋势 + + + Metrics + 指标 + + + 峰值 + 峰值 + + + 均方根 + 均方根 + + + 平均值 + 平均值 + + + 变化量 + 变化量 + + + Session + 会话 + + + Frames + 帧数 + + + Playback + 回放 + + + Running + 运行中 + + + Idle + 空闲 + + + + SaveAsExportDialog + + 导出数据 + 导出数据 + + + 在此位置中搜索 + 在此位置中搜索 + + + 位置 + 位置 + + + 此电脑 + 此电脑 + + + 桌面 + 桌面 + + + 文档 + 文档 + + + 下载 + 下载 + + + 名称 + 名称 + + + 修改时间 + 修改时间 + + + 文件名 + 文件名 + + + 输入要保存的文件名 + 输入要保存的文件名 + + + 文件类型 + 文件类型 + + + 导出方式 + 导出方式 + + + 覆盖导出(同名替换) + 覆盖导出(同名替换) + + + 追加导出(写入同一文件) + 追加导出(写入同一文件) + + + 压缩导出(zip) + 压缩导出(zip) + + + 取消 + 取消 + + + 保存 + 保存 + + + 文件已存在 + 文件已存在 + + + 目标文件已存在,是否覆盖? + 目标文件已存在,是否覆盖? + + + diff --git a/images/china.png b/images/china.png new file mode 100644 index 0000000..c28229c Binary files /dev/null and b/images/china.png differ diff --git a/images/computer_dark.png b/images/computer_dark.png new file mode 100644 index 0000000..8bf5a83 Binary files /dev/null and b/images/computer_dark.png differ diff --git a/images/computer_light.png b/images/computer_light.png new file mode 100644 index 0000000..083dcf9 Binary files /dev/null and b/images/computer_light.png differ diff --git a/images/desktop_dark.png b/images/desktop_dark.png new file mode 100644 index 0000000..d45e20d Binary files /dev/null and b/images/desktop_dark.png differ diff --git a/images/desktop_light.png b/images/desktop_light.png new file mode 100644 index 0000000..9886cc0 Binary files /dev/null and b/images/desktop_light.png differ diff --git a/images/docs_dark.png b/images/docs_dark.png new file mode 100644 index 0000000..8f5e5ca Binary files /dev/null and b/images/docs_dark.png differ diff --git a/images/docs_light.png b/images/docs_light.png new file mode 100644 index 0000000..6b80bb8 Binary files /dev/null and b/images/docs_light.png differ diff --git a/images/download_dark.png b/images/download_dark.png new file mode 100644 index 0000000..21d92b2 Binary files /dev/null and b/images/download_dark.png differ diff --git a/images/download_light.png b/images/download_light.png new file mode 100644 index 0000000..638e61f Binary files /dev/null and b/images/download_light.png differ diff --git a/images/folder_dark.png b/images/folder_dark.png new file mode 100644 index 0000000..72c1163 Binary files /dev/null and b/images/folder_dark.png differ diff --git a/images/folder_light.png b/images/folder_light.png new file mode 100644 index 0000000..ec144e6 Binary files /dev/null and b/images/folder_light.png differ diff --git a/images/united-states.png b/images/united-states.png new file mode 100644 index 0000000..e526254 Binary files /dev/null and b/images/united-states.png differ diff --git a/main.cpp b/main.cpp index f3eea83..20538d5 100644 --- a/main.cpp +++ b/main.cpp @@ -1,74 +1,174 @@ #include -#include +#include #include +#include +#include #include #include -#include +#include #include +#include #include -#include -#include - -#include "backend.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "sparkline_plotitem.h" #include "backend.h" +#include "data_frame.h" #include "glwidget.h" +#include "translation_manager.h" +#include "src/sparkline_plotitem.h" int main(int argc, char *argv[]) { QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL); // 统一OpenGL格式 QSurfaceFormat fmt; + fmt.setRenderableType(QSurfaceFormat::OpenGL); fmt.setVersion(3, 3); fmt.setProfile(QSurfaceFormat::CoreProfile); fmt.setDepthBufferSize(24); fmt.setStencilBufferSize(8); - fmt.setSamples(4); fmt.setSwapInterval(1); QSurfaceFormat::setDefaultFormat(fmt); QApplication a(argc, argv); + QQuickStyle::setStyle("Material"); + + { + QOpenGLContext probeCtx; + probeCtx.setFormat(QSurfaceFormat::defaultFormat()); + if (!probeCtx.create()) { + qCritical().noquote() << "Failed to create an OpenGL context (required: OpenGL 3.3 Core)."; + return 1; + } + + QOffscreenSurface probeSurface; + probeSurface.setFormat(probeCtx.format()); + probeSurface.create(); + + if (!probeCtx.makeCurrent(&probeSurface)) { + qCritical().noquote() + << "Failed to make the OpenGL context current. This usually means the requested format is unsupported by the current graphics driver."; + return 1; + } + + const QSurfaceFormat actual = probeCtx.format(); + const bool versionOk = (actual.majorVersion() > 3) || (actual.majorVersion() == 3 && actual.minorVersion() >= 3); + if (!versionOk || actual.profile() != QSurfaceFormat::CoreProfile) { + probeCtx.doneCurrent(); + qCritical().noquote() + << "OpenGL context is not OpenGL 3.3 Core (got: " + << actual.majorVersion() << "." << actual.minorVersion() << ", profile=" << actual.profile() << ")."; + return 1; + } + + probeCtx.doneCurrent(); + } auto *win = new QMainWindow; + + auto *root = new QWidget; + auto *rootLayout = new QVBoxLayout(root); + rootLayout->setContentsMargins(0, 0, 0, 0); + rootLayout->setSpacing(0); + + AppBackend backend; + TranslationManager i18n; + qmlRegisterSingletonInstance("TactileIPC", 1, 0, "Backend", &backend); + qmlRegisterSingletonInstance("TactileIPC", 1, 0, "I18n", &i18n); + qmlRegisterType("LiveTrend", 1, 0, "SparklinePlot"); + i18n.setLanguage(backend.language()); + QObject::connect(&backend, &AppBackend::languageChanged, &i18n, [&backend, &i18n]() { + i18n.setLanguage(backend.language()); + }); + auto *qmlEngine = new QQmlEngine(root); + + auto createQuickWidget = [&](const QUrl& sourceUrl) -> QQuickWidget* { + auto *view = new QQuickWidget(qmlEngine, root); + view->setResizeMode(QQuickWidget::SizeRootObjectToView); + view->setSource(sourceUrl); + return view; + }; + + auto *navView = createQuickWidget(QUrl("qrc:/qml/content/NavBar.qml")); + navView->setFixedHeight(56); + auto *splitter = new QSplitter; splitter->setOrientation(Qt::Horizontal); - auto *quick = new QQuickWidget; - quick->setResizeMode(QQuickWidget::SizeRootObjectToView); + splitter->setHandleWidth(1); - Backend backend; - quick->rootContext()->setContextProperty("backend", &backend); - quick->setSource(QUrl("qrc:/qml/Main.qml")); + auto *leftView = createQuickWidget(QUrl("qrc:/qml/content/LeftPanel.qml")); + leftView->setFixedWidth(350); auto *glw = new GLWidget; glw->setSpec(8, 11, 0.1f, 0.03f); glw->setPanelThickness(0.08f); - glw->setRange(backend.minValue(), backend.maxValue()); - glw->setRenderModeString(backend.renderMode()); - glw->setLabelModeString(backend.labelMode()); + glw->setRange(0, 1000); - QObject::connect(&backend, &Backend::rangeChanged, glw, &GLWidget::setRange); - QObject::connect(&backend, &Backend::renderModeValueChanged, glw, &GLWidget::setRenderModeString); - QObject::connect(&backend, &Backend::labelModeValueChanged, glw, &GLWidget::setLabelModeString); + /* backend.data()->setLiveRenderCallback([glw](const DataFrame& frame) { + if (frame.data.size() != glw->dotCount()) + return; + glw->submitValues(frame.data); + }); */ + backend.data()->setLiveRenderCallback([](const DataFrame& frame) { + if (frame.data.size() != 0) { + // AA 55 1A 00 34 00 FB 00 1C 00 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 F1 + // aa 55 1a 00 34 00 fb 00 1c 00 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 f1 + qDebug() << "data size: " << frame.data.size(); - auto *t = new QTimer(win); - t->setTimerType(Qt::PreciseTimer); - t->setInterval(10); - QObject::connect(t, &QTimer::timeout, glw, [glw] { - const int n = glw->dotCount(); - QVector v; - v.resize(n); - for (int i = 0; i < n; ++i) { - // TODO: value - v[i] = 100.0f + float(QRandomGenerator::global()->generateDouble()) * (2000.0f - 100.0f); } - glw->submitValues(v); }); + // TODO:待实现内容(将frame数据分发给右侧曲线/指标的QML接口) - splitter->addWidget(quick); + auto *rightView = createQuickWidget(QUrl("qrc:/qml/content/RightPanel.qml")); + + splitter->addWidget(leftView); splitter->addWidget(glw); + splitter->addWidget(rightView); splitter->setStretchFactor(0, 0); splitter->setStretchFactor(1, 1); - win->setCentralWidget(splitter); - win->resize(1100, 650); + splitter->setStretchFactor(2, 0); + splitter->setSizes({320, 640, 320}); + auto applySplitterStyle = [&backend, splitter]() { + const QString handleColor = backend.lightMode() ? QStringLiteral("#E0E0E0") : QStringLiteral("#2C2C2C"); + splitter->setStyleSheet(QStringLiteral("QSplitter::handle { background: %1; }").arg(handleColor)); + }; + applySplitterStyle(); + QObject::connect(&backend, &AppBackend::lightModeChanged, splitter, [applySplitterStyle]() { + applySplitterStyle(); + }); + + auto applyQuickTheme = [&backend, navView, leftView, rightView]() { + const QColor navColor = backend.lightMode() ? QColor(QStringLiteral("#F5F7F5")) : QColor(QStringLiteral("#2B2F2B")); + const QColor panelColor = backend.lightMode() ? QColor(QStringLiteral("#F5F5F5")) : QColor(QStringLiteral("#2C2C2C")); + navView->setClearColor(navColor); + leftView->setClearColor(panelColor); + rightView->setClearColor(panelColor); + }; + applyQuickTheme(); + QObject::connect(&backend, &AppBackend::lightModeChanged, navView, [applyQuickTheme]() { + applyQuickTheme(); + }); + + + QObject::connect(&backend, &AppBackend::lightModeChanged, glw, [&backend, glw]() { + bool m = backend.lightMode() ? true : false; + glw->setLightMode(m); + }); + + QObject::connect(&backend, &AppBackend::showGridChanged, glw, &GLWidget::setShowGrid); + + rootLayout->addWidget(navView); + rootLayout->addWidget(splitter); + + win->setCentralWidget(root); + win->resize(1280, 720); win->show(); - t->start(); return QApplication::exec(); } diff --git a/qml/Main.qml b/qml/Main.qml index e8d6567..5f302c1 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -1,6 +1,5 @@ import QtQuick -import "content" +import "./content" App { - } diff --git a/qml/content/App.qml b/qml/content/App.qml index 6b9311c..de62f29 100644 --- a/qml/content/App.qml +++ b/qml/content/App.qml @@ -1,18 +1,56 @@ import QtQuick -import QtQuick.Window import QtQuick.Controls.Material +import QtQuick.Layouts import "./" +import TactileIPC 1.0 -Rectangle { - // width: Constants.width - // height: Constants.height - width: 360 - // minimumWidth: 800 - // minimumHeight: 600 +Item { + id: root + width: 1280 + height: 720 - visible: true + Material.theme: Backend.lightMode ? Material.Light : Material.Dark + Material.accent: Material.Green + Material.primary: Material.Green - ControlPanel { + ColumnLayout { anchors.fill: parent + spacing: 0 + + NavBar { + Layout.fillWidth: true + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + LeftPanel { + Layout.preferredWidth: 500 + Layout.fillWidth: true + Layout.fillHeight: true + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: 10 + color: Qt.rgba(0, 0, 0, 0.04) + border.color: Qt.rgba(0, 0, 0, 0.08) + + Column { + anchors.centerIn: parent + spacing: 8 + Label { text: "OpenGL View"; font.pixelSize: 16 } + Label { text: "(QWidget GLWidget attached in C++)"; font.pixelSize: 12 } + } + } + + RightPanel { + Layout.preferredWidth: 320 + Layout.fillHeight: true + } + } } } diff --git a/qml/content/CollapsiblePanel.qml b/qml/content/CollapsiblePanel.qml new file mode 100644 index 0000000..cc826bd --- /dev/null +++ b/qml/content/CollapsiblePanel.qml @@ -0,0 +1,117 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material +import QtQuick.Layouts +import TactileIPC 1.0 + +Item { + id: root + width: 350 + + property alias title: titleText.text + property bool expanded: true + property int contentPadding: 12 + property color panelBgColor: Backend.lightMode ? "#FFFFFF" : "#2F2F2F" + property color panelBorderColor: Backend.lightMode ? "#E0E0E0" : "#3A3A3A" + property color titleColor: Backend.lightMode ? "#424242" : "#E0E0E0" + property color dividerColor: Backend.lightMode ? "#EEEEEE" : "#3A3A3A" + + default property alias content: contentArea.data + implicitHeight: header.height + contentWrapper.height + Rectangle { + id: panelBg + anchors.fill: parent + radius: 6 + color: root.panelBgColor + border.color: root.panelBorderColor + } + + Rectangle { + id: header + width: parent.width + height: 44 + radius: 6 + color: "transparent" + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + MouseArea { + anchors.fill: parent + onClicked: root.expanded = !root.expanded + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 8 + spacing: 8 + + Rectangle { + width: 10 + height: 10 + radius: 2 + color: Material.color(Material.Green) + Layout.alignment: Qt.AlignVCenter + } + + Label { + id: titleText + text: "placehold" + font.pixelSize: 16 + color: root.titleColor + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + } + + ToolButton { + id: arrowBtn + text: "\u25BE" + font.pixelSize: 18 + background: null + rotation: root.expanded ? 180 : 0 + Layout.alignment: Qt.AlignVCenter + + Behavior on rotation { + NumberAnimation { + duration: 200; + easing.type: Easing.InOutQuad + } + } + onClicked: root.expanded = !root.expanded + } + } + + Rectangle { + height: 1 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + color: root.dividerColor + visible: true + } + } + + Item { + id: contentWrapper + anchors.top: header.bottom + anchors.left: parent.left + anchors.right: parent.right + height: root.expanded ? contentArea.implicitHeight + root.contentPadding * 2 : 0 + clip: true + + Behavior on height { + NumberAnimation { + duration: 220; + easing.type: Easing.InOutQuad + } + } + + ColumnLayout { + id: contentArea + anchors.fill: parent + anchors.margins: root.contentPadding + spacing: 10 + } + } +} diff --git a/qml/content/ControlPanel.qml b/qml/content/ControlPanel.qml index 2064186..5bf3b06 100644 --- a/qml/content/ControlPanel.qml +++ b/qml/content/ControlPanel.qml @@ -1,10 +1,10 @@ import QtQuick -import QtQuick3D import QtQuick.Controls.Material import QtQuick.Controls import QtQuick.Layouts import QtQml import "." +import TactileIPC 1.0 Pane { id: root @@ -21,11 +21,17 @@ Pane { Toggle { id: darkModeToggle - text: qsTr("Dark mode") + text: Qt.binding(function() { + I18n.retranslateToken + return qsTr("Dark mode") + }) } GroupBox { - title: qsTr("Render") + title: Qt.binding(function() { + I18n.retranslateToken + return qsTr("Render") + }) Layout.fillWidth: true ColumnLayout { @@ -34,17 +40,23 @@ Pane { RowLayout { Layout.fillWidth: true - Label { text: qsTr("Mode"); Layout.alignment: Qt.AlignVCenter } + Label { + text: Qt.binding(function() { + I18n.retranslateToken + return qsTr("Mode") + }) + Layout.alignment: Qt.AlignVCenter + } ComboBox { id: renderModeBox Layout.fillWidth: true model: ["dataViz", "realistic"] - Component.onCompleted: currentIndex = backend.renderMode === "realistic" ? 1 : 0 - onActivated: backend.renderMode = currentText + Component.onCompleted: currentIndex = Backend.renderMode === "realistic" ? 1 : 0 + onActivated: Backend.renderMode = currentText Connections { - target: backend + target: Backend function onRenderModeChanged() { - renderModeBox.currentIndex = backend.renderMode === "realistic" ? 1 : 0 + renderModeBox.currentIndex = Backend.renderMode === "realistic" ? 1 : 0 } } } @@ -52,22 +64,28 @@ Pane { RowLayout { Layout.fillWidth: true - Label { text: qsTr("Labels"); Layout.alignment: Qt.AlignVCenter } + Label { + text: Qt.binding(function() { + I18n.retranslateToken + return qsTr("Labels") + }) + Layout.alignment: Qt.AlignVCenter + } ComboBox { id: labelModeBox Layout.fillWidth: true model: ["off", "hover", "always"] Component.onCompleted: { - if (backend.labelMode === "always") labelModeBox.currentIndex = 2 - else if (backend.labelMode === "hover") labelModeBox.currentIndex = 1 + if (Backend.labelMode === "always") labelModeBox.currentIndex = 2 + else if (Backend.labelMode === "hover") labelModeBox.currentIndex = 1 else labelModeBox.currentIndex = 0 } - onActivated: backend.labelMode = currentText + onActivated: Backend.labelMode = currentText Connections { - target: backend + target: Backend function onLabelModeChanged() { - if (backend.labelMode === "always") labelModeBox.currentIndex = 2 - else if (backend.labelMode === "hover") labelModeBox.currentIndex = 1 + if (Backend.labelMode === "always") labelModeBox.currentIndex = 2 + else if (Backend.labelMode === "hover") labelModeBox.currentIndex = 1 else labelModeBox.currentIndex = 0 } } @@ -76,15 +94,21 @@ Pane { Toggle { id: legendToggle - text: qsTr("Legend") - checked: backend.showLegend - onCheckedChanged: backend.showLegend = checked + text: Qt.binding(function() { + I18n.retranslateToken + return qsTr("Legend") + }) + checked: Backend.showLegend + onCheckedChanged: Backend.showLegend = checked } } } GroupBox { - title: qsTr("Scale") + title: Qt.binding(function() { + I18n.retranslateToken + return qsTr("Scale") + }) Layout.fillWidth: true ColumnLayout { @@ -93,35 +117,47 @@ Pane { RowLayout { Layout.fillWidth: true - Label { text: qsTr("Min"); Layout.alignment: Qt.AlignVCenter } + Label { + text: Qt.binding(function() { + I18n.retranslateToken + return qsTr("Min") + }) + Layout.alignment: Qt.AlignVCenter + } SpinBox { id: minBox Layout.fillWidth: true from: -999999 to: 999999 - value: backend.minValue - onValueModified: backend.minValue = value + value: Backend.minValue + onValueModified: Backend.minValue = value } } RowLayout { Layout.fillWidth: true - Label { text: qsTr("Max"); Layout.alignment: Qt.AlignVCenter } + Label { + text: Qt.binding(function() { + I18n.retranslateToken + return qsTr("Max") + }) + Layout.alignment: Qt.AlignVCenter + } SpinBox { id: maxBox Layout.fillWidth: true from: -999999 to: 999999 - value: backend.maxValue - onValueModified: backend.maxValue = value + value: Backend.maxValue + onValueModified: Backend.maxValue = value } } Legend { Layout.alignment: Qt.AlignHCenter - visible: backend.showLegend - minValue: backend.minValue - maxValue: backend.maxValue + visible: Backend.showLegend + minValue: Backend.minValue + maxValue: Backend.maxValue } } } diff --git a/qml/content/LabeledSlider.qml b/qml/content/LabeledSlider.qml index 1c971d6..9b0f28f 100644 --- a/qml/content/LabeledSlider.qml +++ b/qml/content/LabeledSlider.qml @@ -1,8 +1,12 @@ import QtQuick import QtQuick.Controls +import TactileIPC 1.0 Slider { - property string lableText: qsTr("Text") + property string lableText: Qt.binding(function() { + I18n.retranslateToken + return qsTr("Text") + }) stepSize: 1 Label { diff --git a/qml/content/LeftPanel.qml b/qml/content/LeftPanel.qml new file mode 100644 index 0000000..ffef415 --- /dev/null +++ b/qml/content/LeftPanel.qml @@ -0,0 +1,421 @@ +import QtQuick +import QtQuick.Controls.Material +import QtQuick.Controls +import QtQuick.Layouts +import "." +import TactileIPC 1.0 + +Rectangle { + id: root + width: 350 + color: Backend.lightMode ? "#F5F5F5" : "#2C2C2C" + radius: 8 + border.color: Qt.rgba(0, 0, 0, 0.08) + property color textColor: Backend.lightMode ? "#424242" : "#E0E0E0" + + function formatHexByte(value) { + const hex = Number(value).toString(16).toUpperCase() + return "0x" + ("00" + hex).slice(-2) + } + + function formatHexValue(value) { + const hex = Number(value).toString(16).toUpperCase() + return "0x" + hex + } + + function parseHexValue(text, maxValue) { + let trimmed = String(text).trim() + if (trimmed.startsWith("0x") || trimmed.startsWith("0X")) + trimmed = trimmed.slice(2) + if (trimmed.length === 0) + return NaN + const value = parseInt(trimmed, 16) + if (isNaN(value) || value < 0 || value > maxValue) + return NaN + return value + } + + function tr(text) { + I18n.retranslateToken + return qsTr(text) + } + + Material.theme: Backend.lightMode ? Material.Light : Material.Dark + Material.accent: Material.Green + Material.primary: Material.Green + + ScrollView { + anchors.fill: parent + ScrollBar.vertical.policy: ScrollBar.AlwaysOff + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ColumnLayout { + id: layout + anchors.fill: parent + anchors.margins: 12 + spacing: 12 + + CollapsiblePanel { + title: root.tr("连接设置") + expanded: true + Layout.fillWidth: true + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + text: root.tr("COM Port") + Layout.preferredWidth: 90 + color: root.textColor + } + + ComboBox { + id: portBox + Layout.fillWidth: true + model: Backend.serial.availablePorts + Component.onCompleted: { + for (let i = 0; i < portBox.count; i++) { + if (portBox.textAt(i) === Backend.serial.portName) { + currentIndex = i + break + } + } + } + onActivated: Backend.serial.portName = currentText + } + + ToolButton { + text: "\u21bb" + onClicked: Backend.serial.refreshPorts() + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + text: root.tr("Baud") + Layout.preferredWidth: 90 + color: root.textColor + } + + ComboBox { + Layout.fillWidth: true + model: ["9600", "57600", "115200", "230400", "912600"] + Component.onCompleted: { + const idx = model.indexOf(String(Backend.serial.baudRate)) + if (idx >= 0) + currentIndex = idx + } + onActivated: Backend.serial.baudRate = parseInt(currentText) + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + text: root.tr("模式") + Layout.preferredWidth: 90 + color: root.textColor + } + + ComboBox { + Layout.fillWidth: true + textRole: "text" + model: [ + { text: root.tr("从站"), value: "slave" }, + { text: root.tr("主站"), value: "master" } + ] + function syncModeIndex() { + for (let i = 0; i < model.length; i++) { + if (model[i].value === Backend.serial.mode) { + currentIndex = i + break + } + } + } + Component.onCompleted: syncModeIndex() + onModelChanged: syncModeIndex() + Connections { + target: Backend.serial + function onModeChanged() { + syncModeIndex() + } + } + onActivated: Backend.serial.mode = model[currentIndex].value + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + text: root.tr("设备地址") + Layout.preferredWidth: 90 + color: root.textColor + } + + TextField { + id: addrField + Layout.fillWidth: true + text: root.formatHexByte(Backend.serial.deviceAddress) + placeholderText: "0x01" + inputMethodHints: Qt.ImhPreferUppercase + validator: RegularExpressionValidator { + regularExpression: /^(0x|0X)?[0-9a-fA-F]{1,2}$/ + } + onEditingFinished: { + const value = root.parseHexValue(text, 255) + if (isNaN(value)) { + text = root.formatHexByte(Backend.serial.deviceAddress) + return + } + Backend.serial.deviceAddress = value + text = root.formatHexByte(Backend.serial.deviceAddress) + } + + Connections { + target: Backend.serial + function onDeviceAddressChanged() { + addrField.text = root.formatHexByte(Backend.serial.deviceAddress) + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + text: root.tr("采样周期") + Layout.preferredWidth: 90 + color: root.textColor + } + + SpinBox { + Layout.fillWidth: true + from: 1 + to: 2000 + value: Backend.serial.pollIntervalMs + enabled: Backend.serial.mode === "slave" + onValueModified: Backend.serial.pollIntervalMs = value + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Button { + Layout.fillWidth: true + text: root.tr("连接") + highlighted: true + enabled: !Backend.serial.connected + onClicked: Backend.serial.open() + } + Button { + Layout.fillWidth: true + text: root.tr("断开") + enabled: Backend.serial.connected + onClicked: Backend.serial.close() + } + } + } + + CollapsiblePanel { + title: root.tr("采样参数") + expanded: true + Layout.fillWidth: true + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: root.tr("功能码") + Layout.preferredWidth: 90 + color: root.textColor + } + SpinBox { + id: requestFunctionBox + Layout.fillWidth: true + from: 0 + to: 255 + editable: true + value: Backend.serial.requestFunction + textFromValue: function(value, locale) { + return root.formatHexByte(value) + } + valueFromText: function(text, locale) { + const parsed = root.parseHexValue(text, requestFunctionBox.to) + return isNaN(parsed) ? requestFunctionBox.value : parsed + } + validator: RegularExpressionValidator { + regularExpression: /^(0x|0X)?[0-9a-fA-F]{1,2}$/ + } + onValueModified: Backend.serial.requestFunction = value + } + + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: root.tr("起始地址") + Layout.preferredWidth: 90 + color: root.textColor + } + SpinBox { + id: requestStartAddressBox + Layout.fillWidth: true + from: 0 + to: 1000000 + editable: true + value: Backend.serial.requestStartAddress + textFromValue: function(value, locale) { + return root.formatHexValue(value) + } + valueFromText: function(text, locale) { + const parsed = root.parseHexValue(text, requestStartAddressBox.to) + return isNaN(parsed) ? requestStartAddressBox.value : parsed + } + validator: RegularExpressionValidator { + regularExpression: /^(0x|0X)?[0-9a-fA-F]{1,8}$/ + } + onValueModified: Backend.serial.requestStartAddress = value + } + + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: root.tr("读取长度") + Layout.preferredWidth: 90 + color: root.textColor + } + SpinBox { + Layout.fillWidth: true + from: 0 + to: 65535 + editable: true + value: Backend.serial.requestLength + onValueModified: Backend.serial.requestLength = value + } + } + } + + CollapsiblePanel { + title: root.tr("传感器规格") + expanded: true + Layout.fillWidth: true + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: root.tr("协议") + Layout.preferredWidth: 90 + color: root.textColor + } + Label { + text: Backend.serial.protocol + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + color: root.textColor + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: root.tr("型号") + Layout.preferredWidth: 90 + color: root.textColor + } + Label { + text: Backend.serial.sensorModel + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + color: root.textColor + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: root.tr("规格") + Layout.preferredWidth: 90 + color: root.textColor + } + Label { + text: Backend.serial.sensorGrid + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + color: root.textColor + } + } + + Button { + Layout.fillWidth: true + text: root.tr("重新识别") + highlighted: true + } + } + + CollapsiblePanel { + title: root.tr("显示控制") + expanded: true + Layout.fillWidth: true + + CheckBox { + text: root.tr("显示网络") + checked: Backend.showGrid + onToggled: Backend.showGrid = checked + } + CheckBox { + text: root.tr("显示坐标轴") + checked: false + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Button { + Layout.fillWidth: true + text: root.tr("回放数据") + highlighted: true + } + Button { + Layout.fillWidth: true + text: root.tr("导出数据") + onClicked: exportDlg.open() + } + } + } + + Item { Layout.fillHeight: true } + } + } + SaveAsExportDialog { + id: exportDlg + /* onSaveTo: (folder, filename, format, method) => { + console.log("保存目录:", folder) + console.log("文件名:", filename) + console.log("格式:", format, "方式:", method) + } */ + onSaveTo: (folder, filename, format, method) => { + Backend.data.exportHandler(folder, filename, format, method) + } + } +} diff --git a/qml/content/Legend.qml b/qml/content/Legend.qml index 3757852..7110952 100644 --- a/qml/content/Legend.qml +++ b/qml/content/Legend.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import TactileIPC 1.0 Item { id: root diff --git a/qml/content/LiveTrendCard.qml b/qml/content/LiveTrendCard.qml new file mode 100644 index 0000000..1d5222c --- /dev/null +++ b/qml/content/LiveTrendCard.qml @@ -0,0 +1,123 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import TactileIPC 1.0 +import LiveTrend 1.0 + +Rectangle { + id: root + width: 260 + height: 180 + radius: 10 + color: Backend.lightMode ? "#FFFFFF" : "#2C2C2C" + border.color: Backend.lightMode ? "#E6E6E6" : "#3A3A3A" + border.width: 1 + + property alias plot: plot + property string title: "Live Trend" + + property bool follow: true + property color accentColor: Backend.lightMode ? "#39D353" : "#5BE37A" + property color textColor: Backend.lightMode ? "#2B2B2B" : "#E0E0E0" + property color plotBackground: Backend.lightMode ? "#FFFFFF" : "#1F1F1F" + property color gridColor: Backend.lightMode ? Qt.rgba(0, 0, 0, 0.08) : Qt.rgba(1, 1, 1, 0.08) + property color plotTextColor: Backend.lightMode ? Qt.rgba(0, 0, 0, 0.65) : Qt.rgba(1, 1, 1, 0.65) + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 6 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Rectangle { + width: 8 + height: 8 + radius: 2 + color: root.accentColor + Layout.alignment: Qt.AlignVCenter + } + + Label { + text: root.title + font.pixelSize: 14 + color: root.textColor + Layout.fillWidth: true + elide: Text.ElideRight + } + + ToolButton { + text: root.follow ? "▲" : "↺" + font.pixelSize: 14 + onClicked: { + root.follow = true + plot.follow = true + } + } + } + + // Plot + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: 8 + color: root.plotBackground + + SparklinePlot { + id: plot + anchors.fill: parent + leftPadding: 40 + rightPadding: 6 + topPadding: 8 + bottomPadding: 18 + + yTickCount: 5 + lineColor: root.accentColor + gridColor: root.gridColor + textColor: root.plotTextColor + + viewCount: 5 + follow: root.follow + + // 60fps + Timer { + interval: 16 + running: true + repeat: true + onTriggered: plot.update() + } + + DragHandler { + onActiveChanged: if (active) { + root.follow = false + plot.follow = false + } + onTranslationChanged: (t) => { + let move = Math.round(-t.x / 12) + if (move !== 0) { + plot.viewStart = Math.max(0, plot.viewStart + move) + } + } + } + + WheelHandler { + onWheel: (w) => { + root.follow = false + plot.follow = false + let factor = w.angleDelta.y > 0 ? 0.85 : 1.15 + plot.viewCount = Math.max(10, Math.min(2000000, Math.floor(plot.viewCount * factor))) + } + } + + TapHandler { + onDoubleTapped: { + root.follow = true + plot.follow = true + } + } + } + } + } +} diff --git a/qml/content/NavBar.qml b/qml/content/NavBar.qml new file mode 100644 index 0000000..99d2f30 --- /dev/null +++ b/qml/content/NavBar.qml @@ -0,0 +1,165 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Material +import QtQuick.Layouts +import TactileIPC 1.0 + +Rectangle { + id: root + height: 56 + color: Backend.lightMode ? "#F5F7F5" : "#2B2F2B" + + Material.theme: Backend.lightMode ? Material.Light : Material.Dark + Material.accent: Material.Green + Material.primary: Material.Green + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: Qt.rgba(0, 0, 0, 0.08) + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + spacing: 14 + + Label { + text: "Tactile IPC 3D" + font.pixelSize: 18 + font.weight: Font.DemiBold + color: Material.color(Material.Green) + } + + Item { Layout.fillWidth: true } + + RowLayout { + spacing: 6 + Layout.alignment: Qt.AlignVCenter + + Rectangle { + width: 10 + height: 10 + radius: 5 + color: Backend.connected ? "#2e7d32" : "#d32f2f" + } + + Label { + text: Qt.binding(function() { + I18n.retranslateToken + return Backend.connected ? qsTr("CONNECTED") : qsTr("DISCONNECTED") + }) + font.pixelSize: 12 + font.weight: Font.DemiBold + color: Backend.connected ? "#2e7d32" : "#d32f2f" + } + } + + Switch { + id: themeSwitch + text: Qt.binding(function() { + I18n.retranslateToken + return Backend.lightMode ? qsTr("Light") : qsTr("Dark") + }) + checked: Backend.lightMode + onCheckedChanged: Backend.lightMode = checked + } + + ComboBox { + id: langBox + implicitHeight: 32 + leftPadding: 10 + rightPadding: 26 + Layout.alignment: Qt.AlignVCenter + model: [ + { text: "中文", value: "zh_CN", icon: "qrc:/images/china.png" }, + { text: "English", value: "en_US", icon: "qrc:/images/united-states.png" } + ] + textRole: "text" + delegate: ItemDelegate { + width: langBox.width + height: 28 + onClicked: { + langBox.currentIndex = index + langBox.popup.close() + } + contentItem: Row { + spacing: 8 + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 8 + Image { + width: 18 + height: 12 + fillMode: Image.PreserveAspectFit + source: modelData.icon + } + Label { + text: modelData.text + font: langBox.font + } + } + } + contentItem: Item { + anchors.fill: parent + Row { + spacing: 8 + anchors.verticalCenter: parent.verticalCenter + Image { + width: 18 + height: 12 + fillMode: Image.PreserveAspectFit + source: langBox.model[langBox.currentIndex] + ? langBox.model[langBox.currentIndex].icon + : "" + } + Label { + text: langBox.displayText + font: langBox.font + verticalAlignment: Text.AlignVCenter + } + } + } + popup: Popup { + y: langBox.height + width: langBox.width + implicitHeight: contentItem.implicitHeight + padding: 0 + modal: false + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + popupType: Popup.Window + contentItem: ListView { + implicitHeight: contentHeight + model: langBox.delegateModel + currentIndex: langBox.highlightedIndex + delegate: langBox.delegate + clip: true + } + } + function syncLanguage() { + for (let i = 0; i < model.length; i++) { + if (model[i].value === Backend.language) { + if (currentIndex !== i) + currentIndex = i + return + } + } + } + Component.onCompleted: syncLanguage() + Connections { + target: Backend + function onLanguageChanged() { + langBox.syncLanguage() + } + } + onCurrentIndexChanged: { + if (model[currentIndex]) + Backend.language = model[currentIndex].value + } + } + } +} diff --git a/qml/content/RightPanel.qml b/qml/content/RightPanel.qml new file mode 100644 index 0000000..6c32a6c --- /dev/null +++ b/qml/content/RightPanel.qml @@ -0,0 +1,198 @@ +import QtQuick +import QtQuick.Controls.Material +import QtQuick.Controls +import QtQuick.Layouts +import "." +import TactileIPC 1.0 + +Rectangle { + id: root + width: 340 + color: Backend.lightMode ? "#F5F5F5" : "#2C2C2C" + radius: 8 + border.color: Qt.rgba(0, 0, 0, 0.08) + property color accentColor: "#43a047" + + function tr(text) { + I18n.retranslateToken + return qsTr(text) + } + + Material.theme: Backend.lightMode ? Material.Light : Material.Dark + Material.accent: Material.Green + Material.primary: Material.Green + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 12 + + LiveTrendCard { + id: card + Layout.fillWidth: true + Layout.preferredHeight: 180 + title: root.tr("Payload Sum") + + Connections { + target: Backend.data + function onMetricsChanged() { + if (Backend.data.frameCount > 0) + card.plot.append(Backend.data.metricSum) + } + } + } + + CollapsiblePanel { + title: root.tr("Live Trend") + expanded: true + Layout.fillWidth: true + + + + /* + Canvas { + id: trendCanvas + Layout.fillWidth: true + Layout.preferredHeight: 180 + property var samples: [0.08, 0.24, 0.52, 0.41, 0.63, 0.47, 0.72, 0.58, 0.82, 0.69] + + onPaint: { + const ctx = getContext("2d") + const w = width + const h = height + ctx.clearRect(0, 0, w, h) + + ctx.strokeStyle = "#D8EAD9" + ctx.lineWidth = 1 + for (let i = 1; i < 5; i++) { + const y = (h / 5) * i + ctx.beginPath() + ctx.moveTo(0, y) + ctx.lineTo(w, y) + ctx.stroke() + } + + ctx.strokeStyle = root.accentColor + ctx.lineWidth = 2 + ctx.beginPath() + for (let i = 0; i < samples.length; i++) { + const x = (w - 12) * (i / (samples.length - 1)) + 6 + const y = h - (h - 12) * samples[i] - 6 + if (i === 0) + ctx.moveTo(x, y) + else + ctx.lineTo(x, y) + } + ctx.stroke() + } + + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + Component.onCompleted: requestPaint() + } + */ + } + + CollapsiblePanel { + title: root.tr("Metrics") + expanded: true + Layout.fillWidth: true + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Rectangle { + Layout.fillWidth: true + radius: 6 + color: Qt.rgba(0, 0, 0, 0.03) + border.color: Qt.rgba(0, 0, 0, 0.08) + height: 72 + + Column { + anchors.centerIn: parent + spacing: 4 + Label { text: root.tr("峰值"); font.pixelSize: 12 } + Label { text: Backend.data.metricPeak.toFixed(0); font.pixelSize: 18; font.bold: true } + } + } + + Rectangle { + Layout.fillWidth: true + radius: 6 + color: Qt.rgba(0, 0, 0, 0.03) + border.color: Qt.rgba(0, 0, 0, 0.08) + height: 72 + + Column { + anchors.centerIn: parent + spacing: 4 + Label { text: root.tr("均方根"); font.pixelSize: 12 } + Label { text: Backend.data.metricRms.toFixed(0); font.pixelSize: 18; font.bold: true } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Rectangle { + Layout.fillWidth: true + radius: 6 + color: Qt.rgba(0, 0, 0, 0.03) + border.color: Qt.rgba(0, 0, 0, 0.08) + height: 72 + + Column { + anchors.centerIn: parent + spacing: 4 + Label { text: root.tr("平均值"); font.pixelSize: 12 } + Label { text: Backend.data.metricAvg.toFixed(0); font.pixelSize: 18; font.bold: true } + } + } + + Rectangle { + Layout.fillWidth: true + radius: 6 + color: Qt.rgba(0, 0, 0, 0.03) + border.color: Qt.rgba(0, 0, 0, 0.08) + height: 72 + + Column { + anchors.centerIn: parent + spacing: 4 + Label { text: root.tr("变化量"); font.pixelSize: 12 } + Label { text: Backend.data.metricDelta.toFixed(0); font.pixelSize: 18; font.bold: true } + } + } + } + } + + CollapsiblePanel { + title: root.tr("Session") + expanded: true + Layout.fillWidth: true + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: root.tr("Frames"); Layout.preferredWidth: 80 } + Label { text: Backend.data.frameCount; Layout.fillWidth: true; horizontalAlignment: Text.AlignRight } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { text: root.tr("Playback"); Layout.preferredWidth: 80 } + Label { + text: Backend.data.playbackRunning ? root.tr("Running") : root.tr("Idle") + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + } + } + } + + Item { Layout.fillHeight: true } + } +} diff --git a/qml/content/SaveAsExportDialog.qml b/qml/content/SaveAsExportDialog.qml new file mode 100644 index 0000000..d5d7427 --- /dev/null +++ b/qml/content/SaveAsExportDialog.qml @@ -0,0 +1,561 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Window 2.15 +import Qt.labs.folderlistmodel 2.15 +import QtCore 6.2 +import QtQuick.Dialogs +import TactileIPC 1.0 + +Window { + id: root + width: 980 + height: 640 + minimumWidth: 880 + minimumHeight: 560 + visible: false + modality: Qt.ApplicationModal + flags: Qt.Dialog | Qt.WindowTitleHint | Qt.WindowCloseButtonHint + title: root.tr("导出数据") + color: windowBg + + readonly property bool isDark: !Backend.lightMode + readonly property color windowBg: isDark ? "#1B1F1B" : "#F7F8F9" + Material.accent: root.accent + Material.primary: root.accent + Material.theme: root.isDark ? Material.Dark : Material.Light + + readonly property color accent: "#21A453" + readonly property color accentSoft: root.isDark ? "#1F3A2A" : "#E6F6EC" + readonly property color panel: root.isDark ? "#242924" : "#FFFFFF" + readonly property color border: root.isDark ? "#343A35" : "#E1E5EA" + readonly property color text: root.isDark ? "#E6ECE7" : "#1E2A32" + readonly property color subText: root.isDark ? "#9AA5A0" : "#6E7A86" + readonly property color fieldBg: root.isDark ? "#1E221E" : "#FFFFFF" + readonly property color surfaceAlt: root.isDark ? "#202520" : "#F9FAFB" + readonly property color hoverBg: root.isDark ? "#2C322D" : "#F3F6F8" + readonly property color iconBg: root.isDark ? "#25362B" : "#E8F3EA" + readonly property color iconBgAlt: root.isDark ? "#2A302A" : "#EFF2F5" + readonly property color disabledBg: root.isDark ? "#4B544E" : "#C9D2D8" + readonly property string uiFont: "Microsoft YaHei UI" + + property url currentFolder: StandardPaths.writableLocation(StandardPaths.DocumentsLocation) + "/" + property string chosenFilename: "" + property string exportFormat: formatBox.currentValue + property string exportMethod: methodBox.currentValue + property string lastMethodValue: "overwrite" + + signal saveTo(url folder, string filename, string format, string method) + + function open() { + centerOnScreen_() + visible = true + requestActivate() + } + + function accept() { + visible = false + } + + function reject() { + visible = false + } + + function centerOnScreen_() { + x = Math.round((Screen.width - width) / 2) + y = Math.round((Screen.height - height) / 2) + } + + function normalizeFolder_(path) { + if (!path) + return path + if (path.endsWith("/")) + return path + return path + "/" + } + + function tr(text) { + I18n.retranslateToken + return qsTr(text) + } + + onVisibleChanged: if (visible) centerOnScreen_() + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Rectangle { + Layout.fillWidth: true + height: 54 + radius: 6 + color: root.panel + border.color: root.border + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + ToolButton { + id: backBtn + text: "<" + font.family: root.uiFont + onClicked: nav.back() + background: Rectangle { + radius: 4 + color: backBtn.hovered ? root.accentSoft : "transparent" + border.color: backBtn.hovered ? root.accent : root.border + } + } + ToolButton { + id: forwardBtn + text: ">" + font.family: root.uiFont + onClicked: nav.forward() + background: Rectangle { + radius: 4 + color: forwardBtn.hovered ? root.accentSoft : "transparent" + border.color: forwardBtn.hovered ? root.accent : root.border + } + } + ToolButton { + id: upBtn + text: "^" + font.family: root.uiFont + onClicked: nav.up() + background: Rectangle { + radius: 4 + color: upBtn.hovered ? root.accentSoft : "transparent" + border.color: upBtn.hovered ? root.accent : root.border + } + } + + TextField { + id: breadcrumb + Layout.fillWidth: true + readOnly: true + font.family: root.uiFont + color: root.text + text: root.currentFolder.toString() + background: Rectangle { + radius: 4 + color: root.surfaceAlt + border.color: root.border + } + } + + TextField { + id: searchField + Layout.preferredWidth: 220 + placeholderText: root.tr("在此位置中搜索") + font.family: root.uiFont + background: Rectangle { + radius: 4 + color: root.fieldBg + border.color: root.border + } + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 + + Rectangle { + Layout.preferredWidth: 220 + Layout.fillHeight: true + radius: 6 + color: root.panel + border.color: root.border + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + Label { + text: root.tr("位置") + font.bold: true + font.family: root.uiFont + color: root.text + } + + ListView { + id: places + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: [ + { name: root.tr("此电脑"), url: "file:///", icon: root.isDark ? "qrc:/images/computer_dark.png" : "qrc:/images/computer_light.png" }, + { name: root.tr("桌面"), url: StandardPaths.writableLocation(StandardPaths.DesktopLocation) + "/", icon: root.isDark ? "qrc:/images/desktop_dark.png" : "qrc:/images/desktop_light.png" }, + { name: root.tr("文档"), url: StandardPaths.writableLocation(StandardPaths.DocumentsLocation) + "/", icon: root.isDark ? "qrc:/images/docs_dark.png" : "qrc:/images/docs_light.png" }, + { name: root.tr("下载"), url: StandardPaths.writableLocation(StandardPaths.DownloadLocation) + "/", icon: root.isDark ? "qrc:/images/download_dark.png" : "qrc:/images/download_light.png" } + ] + + delegate: ItemDelegate { + width: ListView.view.width + onClicked: { + places.currentIndex = index + root.currentFolder = normalizeFolder_(modelData.url) + } + + background: Rectangle { + radius: 4 + color: places.currentIndex === index ? root.accentSoft : "transparent" + border.color: places.currentIndex === index ? root.accent : "transparent" + } + + contentItem: RowLayout { + spacing: 8 + Image { + width: 16 + height: 16 + source: modelData.icon + fillMode: Image.PreserveAspectFit + smooth: true + } + Label { + text: modelData.name + font.family: root.uiFont + color: root.text + } + } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: 6 + color: root.panel + border.color: root.border + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 6 + + RowLayout { + Layout.fillWidth: true + Label { text: root.tr("名称"); Layout.fillWidth: true; font.bold: true; font.family: root.uiFont; color: root.text } + Label { text: root.tr("修改时间"); Layout.preferredWidth: 160; font.bold: true; font.family: root.uiFont; color: root.text } + } + Rectangle { Layout.fillWidth: true; height: 1; color: root.border } + + FolderListModel { + id: fileModel + folder: root.currentFolder + showDotAndDotDot: false + showDirs: true + showFiles: false + sortField: FolderListModel.Name + nameFilters: searchField.text.trim().length > 0 + ? ["*" + searchField.text.trim() + "*"] + : ["*"] + } + + ListView { + id: fileList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: fileModel + + delegate: ItemDelegate { + id: fileRow + width: ListView.view.width + onDoubleClicked: { + root.currentFolder = normalizeFolder_(fileModel.get(index, "filePath")) + } + onClicked: { + fileList.currentIndex = index + } + + background: Rectangle { + radius: 4 + color: fileRow.hovered ? root.hoverBg : "transparent" + } + + contentItem: RowLayout { + spacing: 8 + Rectangle { + width: 18 + height: 18 + radius: 3 + color: root.iconBg + border.color: root.border + // TODO: replace with folder icons. + // Text { + // anchors.centerIn: parent + // text: "DIR" + // font.pixelSize: 10 + // color: root.subText + // } + Image { + // anchors.centerIn: parent + width: 16 + height: 16 + source: root.isDark ? "qrc:/images/folder_dark.png" : "qrc:/images/folder_light.png" + fillMode: Image.PreserveAspectFit + smooth: true + } + } + Label { + Layout.fillWidth: true + elide: Text.ElideRight + text: fileModel.get(index, "fileName") || "" + font.family: root.uiFont + color: root.text + } + Label { + Layout.preferredWidth: 160 + text: { + const d = fileModel.get(index, "modified") + return d ? Qt.formatDateTime(d, "yyyy/MM/dd hh:mm") : "" + } + font.family: root.uiFont + color: root.subText + } + } + } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: footerLayout.implicitHeight + 20 + Layout.preferredHeight: implicitHeight + Layout.minimumHeight: implicitHeight + radius: 6 + color: root.panel + border.color: root.border + + GridLayout { + id: footerLayout + anchors.fill: parent + anchors.margins: 10 + columns: 6 + columnSpacing: 10 + rowSpacing: 8 + + Label { text: root.tr("文件名"); font.family: root.uiFont; color: root.text } + TextField { + id: filenameField + Layout.columnSpan: 3 + Layout.fillWidth: true + placeholderText: root.tr("输入要保存的文件名") + font.family: root.uiFont + background: Rectangle { + radius: 4 + color: root.fieldBg + border.color: root.border + } + } + + Label { text: root.tr("文件类型"); font.family: root.uiFont; color: root.text } + ComboBox { + id: formatBox + Layout.fillWidth: true + implicitHeight: 32 + Layout.preferredHeight: implicitHeight + textRole: "label" + valueRole: "value" + font.family: root.uiFont + topPadding: 6 + bottomPadding: 6 + contentItem: Item { + anchors.fill: parent + Text { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 24 + text: formatBox.displayText + font: formatBox.font + color: root.text + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + model: [ + { label: "CSV (*.csv)", value: "csv", suffix: ".csv" }, + { label: "JSON (*.json)", value: "json", suffix: ".json" }, + { label: "Excel (*.xlsx)", value: "xlsx", suffix: ".xlsx" } + ] + background: Rectangle { + radius: 4 + color: root.fieldBg + border.color: root.border + } + } + + Label { text: root.tr("导出方式"); font.family: root.uiFont; color: root.text } + ComboBox { + id: methodBox + Layout.columnSpan: 5 + Layout.fillWidth: true + implicitHeight: 32 + Layout.preferredHeight: implicitHeight + textRole: "label" + valueRole: "value" + font.family: root.uiFont + topPadding: 6 + bottomPadding: 6 + contentItem: Item { + anchors.fill: parent + Text { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 24 + text: methodBox.displayText + font: methodBox.font + color: root.text + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + model: [ + { label: root.tr("覆盖导出(同名替换)"), value: "overwrite" }, + { label: root.tr("追加导出(写入同一文件)"), value: "append" }, + { label: root.tr("压缩导出(zip)"), value: "zip" } + ] + function syncMethodIndex() { + for (let i = 0; i < model.length; i++) { + if (model[i].value === root.lastMethodValue) { + currentIndex = i + break + } + } + } + Component.onCompleted: syncMethodIndex() + onModelChanged: syncMethodIndex() + onCurrentValueChanged: root.lastMethodValue = currentValue + background: Rectangle { + radius: 4 + color: root.fieldBg + border.color: root.border + } + } + + Item { Layout.columnSpan: 4; Layout.fillWidth: true } + + RowLayout { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignRight + spacing: 10 + + Button { + id: cancelBtn + text: root.tr("取消") + font.family: root.uiFont + background: Rectangle { + radius: 4 + color: "transparent" + border.color: root.border + } + contentItem: Text { + text: cancelBtn.text + font.family: root.uiFont + color: root.text + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + onClicked: root.reject() + } + Button { + id: saveBtn + text: root.tr("保存") + enabled: filenameField.text.trim().length > 0 + font.family: root.uiFont + background: Rectangle { + radius: 4 + color: saveBtn.enabled ? root.accent : root.disabledBg + border.color: saveBtn.enabled ? root.accent : root.disabledBg + } + contentItem: Text { + text: saveBtn.text + font.family: root.uiFont + color: "#FFFFFF" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + onClicked: doSave() + } + } + } + } + } + + MessageDialog { + id: overwriteDlg + title: root.tr("文件已存在") + text: root.tr("目标文件已存在,是否覆盖?") + buttons: MessageDialog.Yes | MessageDialog.No + onButtonClicked: function (button, role) { + switch (button) { + case MessageDialog.Yes: + finalizeSave(true) + break + case MessageDialog.No: + break + } + } + } + + function finalizeSave(forceOverwrite) { + root.saveTo(root.currentFolder, chosenFilename, exportFormat, exportMethod) + root.accept() + } + function doSave() { + let name = filenameField.text.trim() + if (name.length === 0) return + + const suffix = formatBox.model[formatBox.currentIndex].suffix + if (!name.endsWith(suffix)) name += suffix + chosenFilename = name + + // TODO: check file existence and show overwriteDlg when needed. + finalizeSave(false) + } + + QtObject { + id: nav + property var backStack: [] + property var forwardStack: [] + + function push(url) { + backStack.push(url) + forwardStack = [] + } + function back() { + if (backStack.length === 0) return + forwardStack.push(root.currentFolder) + root.currentFolder = backStack.pop() + } + function forward() { + if (forwardStack.length === 0) return + backStack.push(root.currentFolder) + root.currentFolder = forwardStack.pop() + } + function up() { + let s = root.currentFolder.toString() + if (s.endsWith("/")) s = s.slice(0, -1) + const idx = s.lastIndexOf("/") + if (idx > 8) root.currentFolder = s.slice(0, idx + 1) + } + } + + onCurrentFolderChanged: { + if (breadcrumb.text && breadcrumb.text !== root.currentFolder.toString()) + nav.push(breadcrumb.text) + breadcrumb.text = root.currentFolder.toString() + } +} diff --git a/qml/content/Toggle.qml b/qml/content/Toggle.qml index ccbbe51..d796e3c 100644 --- a/qml/content/Toggle.qml +++ b/qml/content/Toggle.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import TactileIPC 1.0 Item { property string text diff --git a/resources.qrc b/resources.qrc index 9ebd99c..cba4b23 100644 --- a/resources.qrc +++ b/resources.qrc @@ -1,17 +1,38 @@ + - - qml/Main.qml - qml/content/App.qml - qml/content/ControlPanel.qml - qml/content/Legend.qml - qml/content/LabeledSlider.qml - qml/content/Toggle.qml - shaders/dots.frag - shaders/dots.vert - shaders/bg.frag - shaders/bg.vert - shaders/panel.frag - shaders/panel.vert - images/metal.jpeg - + + qml/Main.qml + qml/content/App.qml + qml/content/NavBar.qml + qml/content/ControlPanel.qml + qml/content/Legend.qml + qml/content/LabeledSlider.qml + qml/content/Toggle.qml + qml/content/LeftPanel.qml + qml/content/RightPanel.qml + qml/content/CollapsiblePanel.qml + shaders/dots.frag + shaders/dots.vert + shaders/bg.frag + shaders/bg.vert + shaders/panel.frag + shaders/panel.vert + images/metal.jpeg + shaders/room.frag + shaders/room.vert + qml/content/LiveTrendCard.qml + qml/content/SaveAsExportDialog.qml + images/computer_dark.png + images/computer_light.png + images/desktop_light.png + images/docs_dark.png + images/download_dark.png + images/download_light.png + images/folder_dark.png + images/folder_light.png + images/docs_light.png + images/desktop_dark.png + images/china.png + images/united-states.png + diff --git a/shaders/bg.frag b/shaders/bg.frag index 2287715..3016202 100644 --- a/shaders/bg.frag +++ b/shaders/bg.frag @@ -7,6 +7,7 @@ uniform vec2 uViewport; // 以像素为单位的网格间距:细网格/粗网格 uniform float uMinorStep; uniform float uMajorStep; +uniform bool uLightMode; // 生成抗锯齿网格线(返回 0..1,1 表示在线上) float gridLine(float stepPx) { @@ -22,21 +23,28 @@ float gridLine(float stepPx) { void main() { vec2 viewport = max(uViewport, vec2(1.0)); vec2 uv = gl_FragCoord.xy / viewport; // 0..1 + + vec3 topCol, botCol, minorCol, majorCol; + float minorStrength, majorStrength; + float vignettePow, vignetteStrength; // 背景渐变:上更亮、下稍灰,常见 3D 软件的“科技感”底色 - vec3 topCol = vec3(0.99, 0.99, 1.00); - vec3 botCol = vec3(0.94, 0.95, 0.98); + topCol = vec3(0.99, 0.99, 1.00); + botCol = vec3(0.94, 0.95, 0.98); + + minorCol = vec3(0.80, 0.82, 0.87); + majorCol = vec3(0.70, 0.73, 0.80); + + minorStrength = 0.22; + majorStrength = 0.35; + vignettePow = 0.12; + vignetteStrength = 0.35; vec3 col = mix(botCol, topCol, uv.y); - // 网格线:细线 + 粗线(每隔一段更深一点) float minor = gridLine(max(uMinorStep, 1.0)); float major = gridLine(max(uMajorStep, 1.0)); - - vec3 minorCol = vec3(0.80, 0.82, 0.87); - vec3 majorCol = vec3(0.70, 0.73, 0.80); - - col = mix(col, minorCol, minor * 0.22); - col = mix(col, majorCol, major * 0.35); + col = mix(col, minorCol, minor * minorStrength); + col = mix(col, majorCol, major * majorStrength); // 轻微 vignette(四角略暗),让画面更“聚焦” vec2 p = uv * 2.0 - 1.0; diff --git a/shaders/dots.frag b/shaders/dots.frag index c6d6fc7..5373972 100644 --- a/shaders/dots.frag +++ b/shaders/dots.frag @@ -117,19 +117,31 @@ void main() { float value01 = clamp((vValue - uMinV) / max(1e-6, (uMaxV - uMinV)), 0.0, 1.0); vec3 dataCol = dataColorRamp(value01); - bool hasData = (uHasData != 0); - vec3 baseColor = hasData ? dataCol : metalBase; + // bool hasData = (uHasData != 0); + // vec3 baseColor = hasData ? dataCol : metalBase; + vec3 baseColor = metalBase; + + // dataViz: flat/unlit, no lighting modulation (keep pure baseColor) + if (uRenderMode == 1) { + FragColor = vec4(clamp(baseColor, 0.0, 1.0), 1.0); + return; + } // Mostly flat, with a slight bevel near the edge to catch highlights. float slope = mix(0.06, 0.28, smoothstep(0.55, 1.0, r01)); - vec3 N = normalize(vec3(p.x * slope, 1.0, p.y * slope)); + // Face the camera: dots live on the panel front face (XY plane), so the base normal points -Z. + // vec3 N = normalize(vec3(p.x * slope, p.y * slope, -1.0)); + vec3 N = normalize(vec3(0.0, 0.15, -1.0)); vec3 V = normalize(uCameraPos - vWorldPos); - float metallic = hasData ? 0.0 : 0.90; - float roughness = hasData ? 0.78 : ((uRenderMode == 1) ? 0.70 : 0.55); + // float metallic = hasData ? 0.0 : 0.90; + // float roughness = hasData ? 0.78 : ((uRenderMode == 1) ? 0.70 : 0.55); + float metallic = 0.90; + float roughness = 0.55; - vec3 keyL = normalize(vec3(0.55, 1.00, 0.25)); - vec3 fillL = normalize(vec3(-0.30, 0.70, -0.80)); + // "Front light": make the light come from the camera direction (like a headlight/flashlight). + vec3 keyL = V; + vec3 fillL = V; vec3 keyC = vec3(1.00, 0.98, 0.95) * 1.8; vec3 fillC = vec3(0.85, 0.90, 1.00) * 0.9; diff --git a/shaders/dots.vert b/shaders/dots.vert index bcaf537..724771c 100644 --- a/shaders/dots.vert +++ b/shaders/dots.vert @@ -12,18 +12,18 @@ out vec3 vWorldPos; uniform mat4 uMVP; // Projection * View * Model(这里 Model 约等于单位矩阵) uniform float uDotRadius; // dot 半径(世界坐标单位) -uniform float uBaseY; // dot 的高度(通常 = panel 顶面 y + 一点点偏移) +uniform float uBaseZ; // dot 的高度(通常 = panel 顶面 y + 一点点偏移) void main() { vUV = aUV; vValue = iValue; // 先确定 dot 的中心点(世界坐标) - vec3 world = vec3(iOffsetXZ.x, uBaseY, iOffsetXZ.y); + vec3 world = vec3(iOffsetXZ.x, iOffsetXZ.y, uBaseZ); // 再把单位 quad 按半径缩放并加到中心点上(让 quad 落在 XZ 平面) world.x += qQuadPos.x * uDotRadius; - world.z += qQuadPos.y * uDotRadius; + world.y += qQuadPos.y * uDotRadius; // 输出裁剪空间坐标(最终会进行透视除法与视口映射,变成屏幕上的像素) vWorldPos = world; diff --git a/shaders/panel.frag b/shaders/panel.frag index 38a1228..8c11d8b 100644 --- a/shaders/panel.frag +++ b/shaders/panel.frag @@ -12,6 +12,7 @@ uniform int uCols; uniform float uPitch; uniform float uDotRadius; uniform int uRenderMode; // 0=realistic, 1=dataViz +uniform bool uLightMode; const float PI = 3.14159265359; @@ -146,10 +147,31 @@ void main() { // ------------------------------------------------------------ // Industrial engineering model: neutral matte gray panel (support layer only) // ------------------------------------------------------------ - vec3 topBase = vec3(0.30, 0.31, 0.32); - vec3 sideBase = vec3(0.27, 0.28, 0.29); + vec3 topBase, sideBase; + vec3 edgeCol, rimCol; + if (uLightMode) { + topBase = vec3(0.30, 0.31, 0.32); + sideBase = vec3(0.27, 0.28, 0.29); + + edgeCol = vec3(0.020); + rimCol = vec3(0.015); + } + else { + topBase = vec3(0.78, 0.80, 0.84); + sideBase = vec3(0.68, 0.70, 0.74); + + edgeCol = vec3(0.010, 0.12, 0.16); + rimCol = vec3(0.12, 0.14, 0.18); + } + vec3 baseColor = mix(sideBase, topBase, isTop); + // dataViz: flat/unlit, no lighting modulation (keep pure baseColor) + if (uRenderMode == 1) { + FragColor = vec4(clamp(baseColor, 0.0, 1.0), 1.0); + return; + } + vec2 xz = vWorldPos.xz; float dotContact = 0.0; if (isTop > 0.5 && uDotRadius > 0.0) { @@ -158,9 +180,11 @@ void main() { dotContact = 1.0 - smoothstep(uDotRadius, uDotRadius + w, d); } - vec3 L = normalize(vec3(0.45, 1.00, 0.20)); + // "Front light": make the light come from the camera direction (like a headlight/flashlight). + vec3 L = V; + // L = normalize(vec3(0.0, 0.15, -1.0)); float diff = saturate(dot(N, L)); - float lighting = 0.90 + 0.10 * diff; + float lighting = uLightMode ? (0.90 + 0.10 * diff) : (0.95 + 0.12 * diff); float hw = max(1e-6, uPanelW * 0.5); float hd = max(1e-6, uPanelD * 0.5); @@ -172,8 +196,8 @@ void main() { float ao = 1.0 - dotContact * 0.08; vec3 col = baseColor * lighting * ao; - col += edgeLine * vec3(0.020); - col += rim * vec3(0.015); + col += edgeLine * edgeCol; + col += rim * rimCol; // Slightly deepen the bottom face to read as thickness, but keep it subtle. float isBottom = step(0.75, -N.y); diff --git a/shaders/room.frag b/shaders/room.frag new file mode 100644 index 0000000..2b91de0 --- /dev/null +++ b/shaders/room.frag @@ -0,0 +1,108 @@ +#version 330 core + +in vec3 vWorldPos; +in vec3 vWorldNormal; +out vec4 FragColor; + +uniform vec3 uCameraPos; +uniform vec3 uRoomHalfSize; +uniform float uMinorStep; +uniform float uMajorStep; +uniform int uRenderMode; // 0=realistic, 1=dataViz (flat/unlit) +uniform bool uLightMode; +uniform bool uShowGrid; + +float saturate(float x) { + return clamp(x, 0.0, 1.0); +} + +float gridLine(vec2 coord, float stepSize) { + stepSize = max(stepSize, 1e-4); + vec2 q = coord / stepSize; + vec2 g = abs(fract(q - 0.5) - 0.5) / fwidth(q); + return 1.0 - min(min(g.x, g.y), 1.0); +} + +vec2 pickGridPlane(vec3 N, vec3 P) { + // 根据朝向选择在哪个平面画网格: + // - 地面/天花板(法线接近 ±Y):用 XZ + // - 前后墙(法线接近 ±Z):用 XY + // - 左右墙(法线接近 ±X):用 ZY + vec3 a = abs(N); + if (a.y > a.x && a.y > a.z) return P.xz; + if (a.z > a.x) return P.xy; + return P.zy; +} + +void main() { + vec3 N = normalize(vWorldNormal); + vec3 V = normalize(uCameraPos - vWorldPos); + + // 区分地面/天花板/墙面配色(简单做个“房间感”) + float isFloor = step(0.8, N.y); + float isCeil = step(0.8, -N.y); + vec3 floorCol; + vec3 wallCol; + vec3 ceilCol; + vec3 minorCol; + vec3 majorCol; + vec3 fogCol; + if (uLightMode) { + floorCol = vec3(0.90, 0.90, 0.92); + wallCol = vec3(0.96, 0.96, 0.98); + ceilCol = vec3(0.98, 0.98, 1.00); + minorCol = vec3(0.78, 0.80, 0.85); + majorCol = vec3(0.68, 0.70, 0.77); + fogCol = vec3(0.985, 0.987, 0.995); + } else { + floorCol = vec3(0.12, 0.13, 0.15); + wallCol = vec3(0.16, 0.17, 0.20); + ceilCol = vec3(0.18, 0.19, 0.22); + minorCol = vec3(0.22, 0.24, 0.28); + majorCol = vec3(0.30, 0.33, 0.40); + fogCol = vec3(0.10, 0.11, 0.13); + } + vec3 baseCol = wallCol; + baseCol = mix(baseCol, floorCol, isFloor); + baseCol = mix(baseCol, ceilCol, isCeil); + + // 在不同面上画网格:小格 + 大格 + if (uShowGrid) { + vec2 plane = pickGridPlane(N, vWorldPos); + float minor = gridLine(plane, uMinorStep); + float major = gridLine(plane, uMajorStep); + baseCol = mix(baseCol, minorCol, minor * 0.18); + baseCol = mix(baseCol, majorCol, major * 0.28); + } + + // dataViz: flat/unlit, no lighting modulation (keep pure baseCol + grid) + if (uRenderMode == 1) { + FragColor = vec4(clamp(baseCol, 0.0, 1.0), 1.0); + return; + } + + // 简单两盏灯:主光 + 补光(够用就好) + // "Front light": make the light come from the camera direction (like a headlight/flashlight). + vec3 keyL = V; + vec3 fillL = V; + float diff1 = max(dot(N, keyL), 0.0); + float diff2 = max(dot(N, fillL), 0.0); + float lighting = 0.65 + 0.25 * diff1 + 0.10 * diff2; + + // 角落稍微压暗,增强“箱体/房间”感觉 + vec3 p = abs(vWorldPos / max(uRoomHalfSize, vec3(1e-4))); + float corner = pow(max(p.x, max(p.y, p.z)), 6.0); + float cornerDark = mix(1.0, 0.80, corner); + + // 轻微雾化:远处更亮一点点,让边界更柔和 + float dist = length(uCameraPos - vWorldPos); + float fog = exp(-dist * 0.06); + vec3 col = baseCol * lighting * cornerDark; + col = mix(fogCol, col, saturate(fog)); + + // 增加一点边缘轮廓(靠观察方向) + float rim = pow(1.0 - saturate(dot(N, V)), 2.0); + col += rim * 0.04; + + FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); +} diff --git a/shaders/room.vert b/shaders/room.vert new file mode 100644 index 0000000..8ab18f6 --- /dev/null +++ b/shaders/room.vert @@ -0,0 +1,19 @@ +#version 330 core + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aN; + +out vec3 vWorldPos; +out vec3 vWorldNormal; + +uniform mat4 uMVP; +uniform vec3 uRoomHalfSize; + +void main() { + // 把单位立方体 [-1,1] 缩放成房间大小 + vec3 world = aPos * uRoomHalfSize; + vWorldPos = world; + vWorldNormal = aN; + gl_Position = uMVP * vec4(world, 1.0); +} + diff --git a/src/backend.cpp b/src/backend.cpp index f6cb8fa..267f69d 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -3,62 +3,48 @@ // #include "backend.h" +#include "data_backend.h" +#include "serial/serial_backend.h" +#include -static QString normalizeRenderMode_(const QString& mode) { - if (mode.compare(QStringLiteral("realistic"), Qt::CaseInsensitive) == 0) - return QStringLiteral("realistic"); - return QStringLiteral("dataViz"); +AppBackend::AppBackend(QObject* parent) + : QObject(parent) + , m_serial(new SerialBackend(this)) + , m_data(new DataBackend(this)) { + m_serial->setFrameCallback([this](const DataFrame& frame) { + m_data->ingestFrame(frame); + }); + + connect(m_serial, &SerialBackend::connectedChanged, this, [this]() { + if (m_serial->connected()) + m_data->clear(); + emit connectedChanged(); + }); } -static QString normalizeLabelMode_(const QString& mode) { - if (mode.compare(QStringLiteral("hover"), Qt::CaseInsensitive) == 0) - return QStringLiteral("hover"); - if (mode.compare(QStringLiteral("always"), Qt::CaseInsensitive) == 0) - return QStringLiteral("always"); - return QStringLiteral("off"); +bool AppBackend::connected() const { + return m_serial && m_serial->connected(); } -Backend::Backend(QObject *parent) - : QObject(parent) { -} - -void Backend::setMinValue(int v) { - if (m_min == v) +void AppBackend::setLanguage(const QString& lang) { + if (m_language == lang) return; - m_min = v; - emit minValueChanged(); - emit rangeChanged(m_min, m_max); + m_language = lang; + emit languageChanged(); } -void Backend::setMaxValue(int v) { - if (m_max == v) +void AppBackend::setLightMode(bool on) { + if (m_lightMode == on) return; - m_max = v; - emit maxValueChanged(); - emit rangeChanged(m_min, m_max); + m_lightMode = on; + emit lightModeChanged(); } -void Backend::setRenderMode(const QString &mode) { - const QString norm = normalizeRenderMode_(mode); - if (m_renderMode == norm) +void AppBackend::setShowGrid(bool on) { + qInfo() << "setShowGrid:" << on; + if (m_showGrid == on) return; - m_renderMode = norm; - emit renderModeChanged(); - emit renderModeValueChanged(m_renderMode); -} -void Backend::setShowLegend(bool show) { - if (m_showLegend == show) - return; - m_showLegend = show; - emit showLegendChanged(); -} - -void Backend::setLabelMode(const QString &mode) { - const QString norm = normalizeLabelMode_(mode); - if (m_labelMode == norm) - return; - m_labelMode = norm; - emit labelModeChanged(); - emit labelModeValueChanged(m_labelMode); -} + m_showGrid = on; + emit showGridChanged(on); +} \ No newline at end of file diff --git a/src/backend.h b/src/backend.h index 1f04a2a..4c0227b 100644 --- a/src/backend.h +++ b/src/backend.h @@ -4,51 +4,50 @@ #ifndef TACTILEIPC3D_BACKEND_H #define TACTILEIPC3D_BACKEND_H -#include +#include #include +#include +#include "data_backend.h" +#include "serial/serial_backend.h" -class Backend : public QObject { +class AppBackend : public QObject { Q_OBJECT - Q_PROPERTY(int minValue READ minValue WRITE setMinValue NOTIFY minValueChanged) - Q_PROPERTY(int maxValue READ maxValue WRITE setMaxValue NOTIFY maxValueChanged) - Q_PROPERTY(QString renderMode READ renderMode WRITE setRenderMode NOTIFY renderModeChanged) - Q_PROPERTY(bool showLegend READ showLegend WRITE setShowLegend NOTIFY showLegendChanged) - Q_PROPERTY(QString labelMode READ labelMode WRITE setLabelMode NOTIFY labelModeChanged) + Q_PROPERTY(bool lightMode READ lightMode WRITE setLightMode NOTIFY lightModeChanged) + Q_PROPERTY(QString language READ language WRITE setLanguage NOTIFY languageChanged) + Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) + Q_PROPERTY(bool showGrid READ showGrid WRITE setShowGrid NOTIFY showGridChanged); + Q_PROPERTY(SerialBackend* serial READ serial CONSTANT) + Q_PROPERTY(DataBackend* data READ data CONSTANT) public: - explicit Backend(QObject* parent = nullptr); + explicit AppBackend(QObject* parent=nullptr); - int minValue() const { return m_min; } - int maxValue() const { return m_max; } - QString renderMode() const { return m_renderMode; } - bool showLegend() const { return m_showLegend; } - QString labelMode() const { return m_labelMode; } + bool lightMode() const { return m_lightMode; } + QString language() const { return m_language; } + bool connected() const; -public slots: - void setMinValue(int v); - void setMaxValue(int v); - void setRenderMode(const QString& mode); - void setShowLegend(bool show); - void setLabelMode(const QString& mode); + SerialBackend* serial() const { return m_serial; } + DataBackend* data() const { return m_data; } + + void setLanguage(const QString& lang); + void setLightMode(bool on); + + bool showGrid() const { return m_showGrid; } + void setShowGrid(bool on); signals: - void minValueChanged(); - void maxValueChanged(); - void renderModeChanged(); - void showLegendChanged(); - void labelModeChanged(); - - void rangeChanged(int minV, int maxV); - void renderModeValueChanged(const QString& mode); - void labelModeValueChanged(const QString& mode); - + void lightModeChanged(); + void languageChanged(); + void connectedChanged(); + void showGridChanged(bool on); private: - int m_min = 100; - int m_max = 2000; - QString m_renderMode = QStringLiteral("dataViz"); - bool m_showLegend = true; - QString m_labelMode = QStringLiteral("off"); + SerialBackend* m_serial = nullptr; + DataBackend* m_data = nullptr; + bool m_lightMode = true; + QString m_language = QStringLiteral("zh_CN"); + + bool m_showGrid = true; }; #endif //TACTILEIPC3D_BACKEND_H diff --git a/src/data_backend.cpp b/src/data_backend.cpp new file mode 100644 index 0000000..3ddce82 --- /dev/null +++ b/src/data_backend.cpp @@ -0,0 +1,338 @@ +#include "data_backend.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +DataBackend::DataBackend(QObject* parent) + : QObject(parent) { + m_playbackTimer.setTimerType(Qt::PreciseTimer); + connect(&m_playbackTimer, &QTimer::timeout, this, [this]() { + if (m_playbackIndex >= m_frames.size()) { + stopPlayback(); + return; + } + const RenderCallback& cb = m_playbackCallback ? m_playbackCallback : m_liveCallback; + emitFrame_(m_frames[m_playbackIndex], cb); + m_playbackIndex++; + }); + seedDebugFrames_(); +} + +void DataBackend::ingestFrame(const DataFrame& frame) { + m_frames.push_back(frame); + emit frameCountChanged(); + emitFrame_(frame, m_liveCallback); +} + +void DataBackend::clear() { + stopPlayback(); + m_frames.clear(); + m_playbackIndex = 0; + updateMetrics_(DataFrame()); + emit frameCountChanged(); +} + +bool DataBackend::exportJson(const QString& path) const { + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) + return false; + file.write(buildJson_()); + return true; +} + +bool DataBackend::exportCsv(const QString& path) const { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) + return false; + file.write(buildCsv_()); + return true; +} + +bool DataBackend::importJson(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return false; + return loadJson_(file.readAll()); +} + +bool DataBackend::importCsv(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return false; + return loadCsv_(file.readAll()); +} + +void DataBackend::startPlayback(int intervalMs) { + if (m_frames.isEmpty()) + return; + if (intervalMs < 1) + intervalMs = 1; + m_playbackIndex = 0; + m_playbackRunning = true; + emit playbackRunningChanged(); + m_playbackTimer.start(intervalMs); +} + +void DataBackend::stopPlayback() { + if (!m_playbackRunning) + return; + m_playbackRunning = false; + emit playbackRunningChanged(); + m_playbackTimer.stop(); +} + +void DataBackend::exportHandler(const QUrl& folder, const QString& filename, + const QString& format, const QString& method) { + const QString trimmedName = filename.trimmed(); + if (trimmedName.isEmpty()) { + qWarning().noquote() << "Export failed: filename is empty."; + return; + } + + QString folderPath = folder.isLocalFile() ? folder.toLocalFile() : folder.toString(); + if (folderPath.startsWith(QStringLiteral("file://"))) + folderPath = QUrl(folderPath).toLocalFile(); + if (folderPath.isEmpty()) { + qWarning().noquote() << "Export failed: folder is empty."; + return; + } + + const QDir dir(folderPath); + if (!dir.exists()) { + qWarning().noquote() << "Export failed: folder does not exist:" << folderPath; + return; + } + + const QString filePath = dir.filePath(trimmedName); + QString fmt = format.trimmed().toLower(); + if (fmt.isEmpty()) { + if (trimmedName.endsWith(QStringLiteral(".csv"), Qt::CaseInsensitive)) + fmt = QStringLiteral("csv"); + else if (trimmedName.endsWith(QStringLiteral(".json"), Qt::CaseInsensitive)) + fmt = QStringLiteral("json"); + else if (trimmedName.endsWith(QStringLiteral(".xlsx"), Qt::CaseInsensitive)) + fmt = QStringLiteral("xlsx"); + } + + QString mode = method.trimmed().toLower(); + if (mode.isEmpty()) + mode = QStringLiteral("overwrite"); + + if (fmt == QStringLiteral("csv")) { + if (mode == QStringLiteral("append")) { + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { + qWarning().noquote() << "Export CSV append failed:" << file.errorString(); + return; + } + file.write(buildCsv_()); + return; + } + if (!exportCsv(filePath)) + qWarning().noquote() << "Export CSV failed:" << filePath; + return; + } + + if (fmt == QStringLiteral("json")) { + if (mode == QStringLiteral("append")) { + QJsonArray arr; + QFile file(filePath); + if (file.exists() && file.open(QIODevice::ReadOnly)) { + const QJsonDocument existing = QJsonDocument::fromJson(file.readAll()); + if (existing.isArray()) + arr = existing.array(); + } + + for (const DataFrame& frame : m_frames) { + QJsonObject obj; + obj.insert(QStringLiteral("pts"), frame.pts); + obj.insert(QStringLiteral("func"), frame.functionCode); + QJsonArray dataArr; + for (float v : frame.data) + dataArr.append(static_cast(v)); + obj.insert(QStringLiteral("data"), dataArr); + arr.append(obj); + } + + QFile out(filePath); + if (!out.open(QIODevice::WriteOnly)) { + qWarning().noquote() << "Export JSON append failed:" << out.errorString(); + return; + } + const QJsonDocument doc(arr); + out.write(doc.toJson(QJsonDocument::Indented)); + return; + } + if (!exportJson(filePath)) + qWarning().noquote() << "Export JSON failed:" << filePath; + return; + } + + if (fmt == QStringLiteral("xlsx")) { + qWarning().noquote() << "Export XLSX not supported yet:" << filePath; + return; + } + + if (mode == QStringLiteral("zip")) { + qWarning().noquote() << "Export ZIP not supported yet:" << filePath; + return; + } + + qWarning().noquote() << "Export failed: unsupported format:" << format; +} + +bool DataBackend::loadJson_(const QByteArray& data) { + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isNull() || !doc.isArray()) + return false; + + stopPlayback(); + clear(); + const QJsonArray arr = doc.array(); + for (const QJsonValue& val : arr) { + if (!val.isObject()) + continue; + const QJsonObject obj = val.toObject(); + DataFrame frame; + frame.pts = obj.value(QStringLiteral("pts")).toString(); + frame.functionCode = static_cast(obj.value(QStringLiteral("func")).toInt()); + + const QJsonArray dataArr = obj.value(QStringLiteral("data")).toArray(); + frame.data.reserve(dataArr.size()); + for (const QJsonValue& d : dataArr) + frame.data.push_back(static_cast(d.toDouble())); + + m_frames.push_back(frame); + } + + emit frameCountChanged(); + return true; +} + +bool DataBackend::loadCsv_(const QByteArray& data) { + stopPlayback(); + clear(); + QString text = QString::fromUtf8(data); + QTextStream stream(&text); + while (!stream.atEnd()) { + const QString line = stream.readLine().trimmed(); + if (line.isEmpty()) + continue; + + const QStringList parts = line.split(',', Qt::KeepEmptyParts); + if (parts.size() < 2) + continue; + if (parts[0].trimmed().toLower() == QStringLiteral("pts")) + continue; + + DataFrame frame; + frame.pts = parts[0]; + frame.functionCode = 0; + for (int i = 1; i < parts.size(); ++i) + frame.data.push_back(parts[i].toFloat()); + m_frames.push_back(frame); + } + + emit frameCountChanged(); + return true; +} + +QByteArray DataBackend::buildJson_() const { + QJsonArray arr; + for (const DataFrame& frame : m_frames) { + QJsonObject obj; + obj.insert(QStringLiteral("pts"), frame.pts); + obj.insert(QStringLiteral("func"), frame.functionCode); + QJsonArray dataArr; + for (float v : frame.data) + dataArr.append(static_cast(v)); + obj.insert(QStringLiteral("data"), dataArr); + arr.append(obj); + } + const QJsonDocument doc(arr); + return doc.toJson(QJsonDocument::Indented); +} + +QByteArray DataBackend::buildCsv_() const { + QByteArray out; + QTextStream stream(&out, QIODevice::WriteOnly); + for (const DataFrame& frame : m_frames) { + stream << frame.pts; + for (float v : frame.data) + stream << ',' << v; + stream << '\n'; + } + return out; +} + +void DataBackend::seedDebugFrames_() { + if (!m_frames.isEmpty()) + return; + + constexpr int kFrameCount = 3; + constexpr int kSampleCount = 12; + constexpr int kBase = 100; + + const QDateTime start = QDateTime::currentDateTime(); + m_frames.reserve(kFrameCount); + for (int f = 0; f < kFrameCount; ++f) { + DataFrame frame; + frame.pts = DataFrame::makePts(start.addMSecs(f * 100)); + frame.functionCode = 1; + frame.data.reserve(kSampleCount); + for (int i = 0; i < kSampleCount; ++i) + frame.data.push_back(static_cast(kBase + f * 2 + i * 5)); + m_frames.push_back(frame); + } + + emit frameCountChanged(); +} +void DataBackend::emitFrame_(const DataFrame& frame, const RenderCallback& cb) { + updateMetrics_(frame); + if (cb) + cb(frame); +} + +void DataBackend::updateMetrics_(const DataFrame& frame) { + double peak = 0.0; + double rms = 0.0; + double avg = 0.0; + double delta = 0.0; + double sum = 0.0; + + if (!frame.data.isEmpty()) { + double sumSq = 0.0; + float minV = frame.data.front(); + float maxV = frame.data.front(); + + for (float v : frame.data) { + sum += v; + sumSq += static_cast(v) * static_cast(v); + if (v < minV) + minV = v; + if (v > maxV) + maxV = v; + } + + const double count = static_cast(frame.data.size()); + avg = sum / count; + rms = std::sqrt(sumSq / count); + peak = maxV; + delta = maxV - minV; + } + + m_metricPeak = peak; + m_metricRms = rms; + m_metricAvg = avg; + m_metricDelta = delta; + m_metricSum = sum; + emit metricsChanged(); +} diff --git a/src/data_backend.h b/src/data_backend.h new file mode 100644 index 0000000..9d9351c --- /dev/null +++ b/src/data_backend.h @@ -0,0 +1,83 @@ +#ifndef TACTILEIPC3D_DATA_BACKEND_H +#define TACTILEIPC3D_DATA_BACKEND_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include "data_frame.h" + +class DataBackend : public QObject { + Q_OBJECT + Q_PROPERTY(int frameCount READ frameCount NOTIFY frameCountChanged) + Q_PROPERTY(bool playbackRunning READ playbackRunning NOTIFY playbackRunningChanged) + Q_PROPERTY(double metricPeak READ metricPeak NOTIFY metricsChanged) + Q_PROPERTY(double metricRms READ metricRms NOTIFY metricsChanged) + Q_PROPERTY(double metricAvg READ metricAvg NOTIFY metricsChanged) + Q_PROPERTY(double metricDelta READ metricDelta NOTIFY metricsChanged) + Q_PROPERTY(double metricSum READ metricSum NOTIFY metricsChanged) + +public: + using RenderCallback = std::function; + + explicit DataBackend(QObject* parent = nullptr); + + int frameCount() const { return m_frames.size(); } + bool playbackRunning() const { return m_playbackRunning; } + double metricPeak() const { return m_metricPeak; } + double metricRms() const { return m_metricRms; } + double metricAvg() const { return m_metricAvg; } + double metricDelta() const { return m_metricDelta; } + double metricSum() const { return m_metricSum; } + + void setLiveRenderCallback(RenderCallback cb) { m_liveCallback = std::move(cb); } + void setPlaybackRenderCallback(RenderCallback cb) { m_playbackCallback = std::move(cb); } + + void ingestFrame(const DataFrame& frame); + + Q_INVOKABLE void clear(); + Q_INVOKABLE bool exportJson(const QString& path) const; + Q_INVOKABLE bool exportCsv(const QString& path) const; + Q_INVOKABLE bool importJson(const QString& path); + Q_INVOKABLE bool importCsv(const QString& path); + Q_INVOKABLE void startPlayback(int intervalMs); + Q_INVOKABLE void stopPlayback(); + Q_INVOKABLE void exportHandler(const QUrl& folder, const QString& filename, + const QString& format, const QString& method); + +signals: + void frameCountChanged(); + void playbackRunningChanged(); + void metricsChanged(); + +private: + bool loadJson_(const QByteArray& data); + bool loadCsv_(const QByteArray& data); + QByteArray buildJson_() const; + QByteArray buildCsv_() const; + + void seedDebugFrames_(); + + void emitFrame_(const DataFrame& frame, const RenderCallback& cb); + void updateMetrics_(const DataFrame& frame); + + QVector m_frames; + QTimer m_playbackTimer; + int m_playbackIndex = 0; + bool m_playbackRunning = false; + + double m_metricPeak = 0.0; + double m_metricRms = 0.0; + double m_metricAvg = 0.0; + double m_metricDelta = 0.0; + double m_metricSum = 0.0; + + RenderCallback m_liveCallback; + RenderCallback m_playbackCallback; +}; + +#endif // TACTILEIPC3D_DATA_BACKEND_H diff --git a/src/data_frame.h b/src/data_frame.h new file mode 100644 index 0000000..9226997 --- /dev/null +++ b/src/data_frame.h @@ -0,0 +1,19 @@ +#ifndef TACTILEIPC3D_DATA_FRAME_H +#define TACTILEIPC3D_DATA_FRAME_H + +#include +#include +#include +#include + +struct DataFrame { + QString pts; + quint8 functionCode = 0; + QVector data; + + static QString makePts(const QDateTime& dt) { + return dt.toString(QStringLiteral("yyyyMMddhhmmsszzz")); + } +}; + +#endif // TACTILEIPC3D_DATA_FRAME_H diff --git a/src/glwidget.cpp b/src/glwidget.cpp index 7d7ea8a..b57d94b 100644 --- a/src/glwidget.cpp +++ b/src/glwidget.cpp @@ -8,13 +8,21 @@ #include #include #include +#include #include +#include +#include +#include +#include +#include +#include #include #include #include #include #include #include +#include #include // 读取文本文件内容(这里主要用来从 Qt Resource `:/shaders/...` 读取 shader 源码) @@ -77,7 +85,7 @@ void GLWidget::setPanelSize(float w, float h, float d) { void GLWidget::setPanelThickness(float h) { if (qFuzzyCompare(m_panelH, h)) return; - m_panelH = h; + m_panelD = h; m_panelGeometryDirty = true; update(); } @@ -91,9 +99,9 @@ void GLWidget::setSpec(int rows, int cols, float pitch, float dotRaius) { // 自动根据点阵尺寸调整 panel 的尺寸(保证圆点不会越过顶面边界)。 // 约定:点中心覆盖的范围是 (cols-1)*pitch / (rows-1)*pitch,面板需要额外留出 dotRadius 的边缘空间。 const float gridW = float(qMax(0, m_cols - 1)) * m_pitch; - const float gridD = float(qMax(0, m_rows - 1)) * m_pitch; + const float gridH = float(qMax(0, m_rows - 1)) * m_pitch; m_panelW = gridW + 2.0f * m_dotRadius; - m_panelD = gridD + 2.0f * m_dotRadius; + m_panelH = gridH + 2.0f * m_dotRadius; m_panelGeometryDirty = true; m_dotsGeometryDirty = true; @@ -166,6 +174,23 @@ void GLWidget::setLabelModeString(const QString &mode) { update(); } +void GLWidget::setLightMode(bool on = true) { + if (on == m_lightMode) { + return; + } + + m_lightMode = on; + update(); +} + +void GLWidget::setShowBg(bool on = true) { + if (on == m_showBg) { + return; + } + m_showBg = on; + update(); +} + void GLWidget::initializeGL() { initializeOpenGLFunctions(); @@ -177,6 +202,7 @@ void GLWidget::initializeGL() { initBackgroundGeometry_(); initPanelGeometry_(); initDotGeometry_(); + initRoomGeometry_(); m_panelGeometryDirty = false; m_dotsGeometryDirty = false; @@ -205,7 +231,7 @@ void GLWidget::paintGL() { } // --- 背景:屏幕空间网格(不随相机旋转)--- - if (m_bgProg && m_bgVao) { + if (m_bgProg && m_bgVao && m_showBg) { const float dpr = devicePixelRatioF(); const QVector2D viewport(float(width()) * dpr, float(height()) * dpr); @@ -229,6 +255,7 @@ void GLWidget::paintGL() { // 1) 更新相机/投影矩阵(MVP),决定如何把 3D 世界投影到屏幕 updateMatrices_(); + updateRoom_(); // 2) 如果外部提交了新数据,就把 CPU 生成的 instance 数据更新到 GPU updateInstanceBufferIfNeeded_(); @@ -245,7 +272,8 @@ void GLWidget::paintGL() { m_panelProg->setUniformValue("uCols", m_cols); m_panelProg->setUniformValue("uPitch", m_pitch); m_panelProg->setUniformValue("uDotRadius", m_dotRadius); - m_panelProg->setUniformValue("uRenderMode", int(m_renderMode)); + m_panelProg->setUniformValue("uRenderMode", 1); + m_panelProg->setUniformValue("uLightMode", m_lightMode); glBindVertexArray(m_panelVao); glDrawElements(GL_TRIANGLES, m_panelIndexCount, GL_UNSIGNED_INT, nullptr); glBindVertexArray(0); @@ -257,16 +285,16 @@ void GLWidget::paintGL() { // uniforms:每次 draw 前设置的一组“常量参数”(对当前 draw call 的所有 instance 都一致) // uMVP: 同上;用于把每个 dot 的世界坐标变换到屏幕 m_dotsProg->setUniformValue("uMVP", m_mvp); - m_dotsProg->setUniformValue("uRenderMode", int(m_renderMode)); + m_dotsProg->setUniformValue("uRenderMode", 1); // uDotRadius: dot 的半径(世界坐标单位) m_dotsProg->setUniformValue("uDotRadius", m_dotRadius); // uBaseY: dot 的高度(放在 panel 顶面上方一点点,避免 z-fighting/闪烁) - m_dotsProg->setUniformValue("uBaseY", (m_panelH * 0.5f) + 0.001f); + m_dotsProg->setUniformValue("uBaseZ", -(m_panelD * 0.5f) - 0.001f); // uMinV/uMaxV: 传感值范围,用于 fragment shader 把 value 映射成颜色 m_dotsProg->setUniformValue("uMinV", float(m_min)); m_dotsProg->setUniformValue("uMaxV", float(m_max)); const int hasData = (m_hasData.load() || m_dotTex == 0) ? 1 : 0; - m_dotsProg->setUniformValue("uHasData", hasData); + m_dotsProg->setUniformValue("uHasData", 0); m_dotsProg->setUniformValue("uCameraPos", m_cameraPos); m_dotsProg->setUniformValue("uDotTex", 0); if (m_dotTex) { @@ -282,7 +310,7 @@ void GLWidget::paintGL() { glBindTexture(GL_TEXTURE_2D, 0); } - m_dotsProg->release(); + // m_dotsProg->release(); } if (m_labelMode != LabelsOff && dotCount() > 0) { @@ -396,6 +424,25 @@ void GLWidget::mousePressEvent(QMouseEvent *event) { event->accept(); return; } + if (event->button() == Qt::LeftButton) { + QVector3D world; + const int index = pickDotIndex_(event->pos(), &world); + if (index >= 0) { + float value = 0.0f; + int row = 0; + int col = 0; + if (m_cols > 0) { + row = index / m_cols; + col = index % m_cols; + } + { + QMutexLocker lk(&m_dataMutex); + if (index < m_latestValues.size()) + value = m_latestValues[index]; + } + emit dotClicked(index, row, col, value); + } + } QOpenGLWidget::mousePressEvent(event); } @@ -483,11 +530,75 @@ void GLWidget::mouseReleaseEvent(QMouseEvent *event) { void GLWidget::wheelEvent(QWheelEvent *event) { const float steps = event->angleDelta().y() / 120.0f; const float factor = std::pow(0.9f, steps); - m_zoom_ = qBound(0.2f, m_zoom_ * factor, 45.0f); + m_zoom_ = qBound(0.2f, m_zoom_ * factor, 90.0f); update(); event->accept(); } +bool GLWidget::projectToScreen_(const QVector3D& world, QPointF* out) const { + if (!out) + return false; + const QVector4D clip = m_mvp * QVector4D(world, 1.0f); + if (clip.w() <= 1e-6f) + return false; + const QVector3D ndc = clip.toVector3D() / clip.w(); + if (ndc.z() < -1.2f || ndc.z() > 1.2f) + return false; + + out->setX((ndc.x() * 0.5f + 0.5f) * float(width())); + out->setY((1.0f - (ndc.y() * 0.5f + 0.5f)) * float(height())); + return true; +} + +int GLWidget::pickDotIndex_(const QPoint& pos, QVector3D* worldOut) const { + if (dotCount() <= 0) + return -1; + + const float baseY = (m_panelH * 0.5f) + 0.001f; + const float w = (m_cols - 1) * m_pitch; + const float h = (m_rows - 1) * m_pitch; + + int best = -1; + float bestDist2 = std::numeric_limits::infinity(); + QVector3D bestWorld; + + for (int i = 0; i < dotCount(); ++i) { + const int rr = (m_cols > 0) ? (i / m_cols) : 0; + const int cc = (m_cols > 0) ? (i % m_cols) : 0; + const QVector3D worldCenter( + (cc * m_pitch) - w * 0.5f, + baseY, + (rr * m_pitch) - h * 0.5f + ); + + QPointF center; + if (!projectToScreen_(worldCenter, ¢er)) + continue; + + QPointF edge; + if (!projectToScreen_(worldCenter + QVector3D(m_dotRadius, 0.0f, 0.0f), &edge)) + continue; + + const float radDx = float(edge.x() - center.x()); + const float radDy = float(edge.y() - center.y()); + const float radPx = std::sqrt(radDx * radDx + radDy * radDy); + const float threshold = radPx + 6.0f; + + const float dx = float(pos.x()) - float(center.x()); + const float dy = float(pos.y()) - float(center.y()); + const float dist2 = dx * dx + dy * dy; + if (dist2 <= threshold * threshold && dist2 < bestDist2) { + best = i; + bestDist2 = dist2; + bestWorld = worldCenter; + } + } + + if (best >= 0 && worldOut) + *worldOut = bestWorld; + return best; +} + void GLWidget::initBackgroundGeometry_() { if (m_bgVbo) { glDeleteBuffers(1, &m_bgVbo); @@ -675,7 +786,6 @@ void GLWidget::initDotGeometry_() { glBindVertexArray(0); } - void GLWidget::initDotTexture_() { if (m_dotTex) { glDeleteTextures(1, &m_dotTex); @@ -712,6 +822,91 @@ void GLWidget::initDotTexture_() { glBindTexture(GL_TEXTURE_2D, 0); } +void GLWidget::initRoomGeometry_() { + qInfo() << "initRoomGeometry_()"; + if (room_vao) { + glDeleteVertexArrays(1, &room_vao); + room_vao = 0; + } + if (room_vbo) { + glDeleteBuffers(1, &room_vbo); + room_vbo = 0; + } + if (room_ibo) { + glDeleteBuffers(1, &room_ibo); + room_ibo = 0; + } + + using V = struct { + float x, y, z; + float nx, ny, nz; + }; + + V verts[24] = { + // floor (y = -1), normal +Y + {-1, -1, -1, 0, +1, 0}, + {+1, -1, -1, 0, +1, 0}, + {+1, -1, +1, 0, +1, 0}, + {-1, -1, +1, 0, +1, 0}, + + // ceiling (y = +1), normal -Y + {-1, +1, +1, 0, -1, 0}, + {+1, +1, +1, 0, -1, 0}, + {+1, +1, -1, 0, -1, 0}, + {-1, +1, -1, 0, -1, 0}, + + // back wall (z = -1), normal +Z + {-1, +1, -1, 0, 0, +1}, + {+1, +1, -1, 0, 0, +1}, + {+1, -1, -1, 0, 0, +1}, + {-1, -1, -1, 0, 0, +1}, + + // front wall (z = +1), normal -Z + {+1, +1, +1, 0, 0, -1}, + {-1, +1, +1, 0, 0, -1}, + {-1, -1, +1, 0, 0, -1}, + {+1, -1, +1, 0, 0, -1}, + + // left wall (x = -1), normal +X + {-1, +1, +1, +1, 0, 0}, + {-1, +1, -1, +1, 0, 0}, + {-1, -1, -1, +1, 0, 0}, + {-1, -1, +1, +1, 0, 0}, + + // right wall (x = +1), normal -X + {+1, +1, -1, -1, 0, 0}, + {+1, +1, +1, -1, 0, 0}, + {+1, -1, +1, -1, 0, 0}, + {+1, -1, -1, -1, 0, 0}, + }; + unsigned int idx[36] = { + 0, 1, 2, 0, 2, 3, + 4, 5, 6, 4, 6, 7, + 8, 9, 10, 8, 10, 11, + 12, 13, 14, 12, 14, 15, + 16, 17, 18, 16, 18, 19, + 20, 21, 22, 20, 22, 23 + }; + room_index_count = 36; + + glGenVertexArrays(1, &room_vao); + glBindVertexArray(room_vao); + + glGenBuffers(1, &room_vbo); + glBindBuffer(GL_ARRAY_BUFFER, room_vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW); + + glGenBuffers(1, &room_ibo); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, room_ibo); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(idx), idx, GL_STATIC_DRAW); + + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(V), (void*)0); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(V), (void*)(3 * sizeof(float))); + + glBindVertexArray(0); +} void GLWidget::initPrograms_() { // Qt Resource 里打包的 shader 文件路径:使用 `:/` 前缀(不是文件系统路径) const QString vsd_path = QStringLiteral(":/shaders/dots.vert"); @@ -720,6 +915,8 @@ void GLWidget::initPrograms_() { const QString fsb_path = QStringLiteral(":/shaders/bg.frag"); const QString vsp_path = QStringLiteral(":/shaders/panel.vert"); const QString fsp_path = QStringLiteral(":/shaders/panel.frag"); + const QString vsr_path = QStringLiteral(":/shaders/room.vert"); + const QString fsr_path = QStringLiteral(":/shaders/room.frag"); { auto *p = new QOpenGLShaderProgram; @@ -757,6 +954,18 @@ void GLWidget::initPrograms_() { } m_dotsProg = p; } + { + auto *p = new QOpenGLShaderProgram; + const bool okV = p->addShaderFromSourceCode(QOpenGLShader::Vertex, readFile(vsr_path)); + const bool okF = p->addShaderFromSourceCode(QOpenGLShader::Fragment, readFile(fsr_path)); + const bool okL = okV && okF && p->link(); + if (!okL) { + qWarning() << "dots program build failed:" << vsr_path << fsr_path << p->log(); + delete p; + p = nullptr; + } + m_roomProg = p; + } } void GLWidget::updateInstanceBufferIfNeeded_() { @@ -825,7 +1034,7 @@ void GLWidget::updateMatrices_() { const float distance = qMax(0.5f, radius * 2.5f); // 让相机看向 panel 的中心点 - const QVector3D center(0.0f, m_panelH * 0.5f, 0.0f); + const QVector3D center(0.0f, 0.0f, 0.0f); // yaw/pitch 控制相机绕目标点“环绕”(orbit camera) const float yawRad = qDegreesToRadians(m_camYawDeg); @@ -849,3 +1058,43 @@ void GLWidget::updateMatrices_() { m_mvp = proj * view; } + +void GLWidget::updateRoom_(){ + if (!m_roomProg || !room_vao || room_index_count <= 0) { + return; + } + + m_roomProg->bind(); + + const float base = std::max({ m_panelW, m_panelH, m_panelD }); + const QVector3D roomHalfSize( + std::max(1.0f, base * 1.5f), + std::max(1.0f, base * 1.5f), + std::max(1.0f, base * 1.5f) + ); + + const float minorStep = std::max(0.05f, base * 0.25f); + const float majorStep = minorStep * 5.0f; + + m_roomProg->setUniformValue("uMVP", m_mvp); + m_roomProg->setUniformValue("uCameraPos", m_cameraPos); + m_roomProg->setUniformValue("uRoomHalfSize", roomHalfSize); + m_roomProg->setUniformValue("uMinorStep", minorStep); + m_roomProg->setUniformValue("uMajorStep", majorStep); + m_roomProg->setUniformValue("uRenderMode", 1); + m_roomProg->setUniformValue("uLightMode", m_lightMode); + m_roomProg->setUniformValue("uShowGrid", m_showGrid); + + glBindVertexArray(room_vao); + glDrawElements(GL_TRIANGLES, room_index_count, GL_UNSIGNED_INT, nullptr); + glBindVertexArray(0); + m_roomProg->release(); +} + +void GLWidget::setShowGrid(bool on) { + if (m_showGrid == on) + return; + + m_showGrid = on; + update(); +} diff --git a/src/glwidget.h b/src/glwidget.h index ae9d9cf..5813522 100644 --- a/src/glwidget.h +++ b/src/glwidget.h @@ -1,10 +1,11 @@ -// + // Created by Lenn on 2025/12/16. // #ifndef TACTILEIPC3D_GLWIDGET_H #define TACTILEIPC3D_GLWIDGET_H +#include #include #include #include @@ -14,10 +15,18 @@ #include #include #include +#include +#include + +struct Ray { + QVector3D origin; + QVector3D dir; +}; class GLWidget : public QOpenGLWidget, protected QOpenGLFunctions_3_3_Core { Q_OBJECT Q_PROPERTY(float yaw READ yaw WRITE setYaw NOTIFY yawChanged) + // Q_PROPERTY(bool showGrid READ showGrid WRITE setShowGrid NOTIFY showGridChanged) public: enum RenderMode { Realistic = 0, @@ -48,15 +57,23 @@ public: float yaw() const { return m_camYawDeg; } + bool showGrid() const { return m_showGrid; } + + + public slots: // 值域范围,用于 shader 里把 value 映射到颜色(绿->红) void setRange(int minV, int maxV); void setYaw(float yawDeg); void setRenderModeString(const QString& mode); void setLabelModeString(const QString& mode); + void setLightMode(bool on); + void setShowBg(bool on); + void setShowGrid(bool on); signals: void yawChanged(); + void dotClicked(int index, int row, int col, float value); protected: void initializeGL() override; @@ -73,9 +90,12 @@ private: void initBackgroundGeometry_(); void initPrograms_(); void initDotTexture_(); + void initRoomGeometry_(); void updateInstanceBufferIfNeeded_(); void updateMatrices_(); - + void updateRoom_(); + bool projectToScreen_(const QVector3D& world, QPointF* out) const; + int pickDotIndex_(const QPoint& pos, QVector3D* worldOut) const; private: // 传感值范围(用于颜色映射) int m_min = 0; @@ -86,9 +106,9 @@ private: int m_cols = 4; // panel: 一个长方体/板子(当前只画顶面矩形) - float m_panelW = 1.2f; - float m_panelH = 0.08f; - float m_panelD = 0.08f; + float m_panelW = 0.25f; + float m_panelH = 0.35f; + float m_panelD = 0.05f; // 点阵布局参数 float m_pitch = 0.1f; @@ -103,6 +123,7 @@ private: // shader program(编译/链接后的可执行 GPU 程序) QOpenGLShaderProgram* m_bgProg = nullptr; + QOpenGLShaderProgram* m_roomProg = nullptr; QOpenGLShaderProgram* m_panelProg = nullptr; QOpenGLShaderProgram* m_dotsProg = nullptr; @@ -119,11 +140,20 @@ private: unsigned int m_dotsVao = 0; unsigned int m_dotsVbo = 0; unsigned int m_instanceVbo = 0; + + unsigned int room_vao = 0; + unsigned int room_vbo = 0; + unsigned int room_ibo = 0; + int room_index_count = 0; + bool m_showGrid = true; + int m_instanceCount = 0; bool m_dotsGeometryDirty = false; unsigned int m_bgVao = 0; unsigned int m_bgVbo = 0; + bool m_lightMode = true; + bool m_showBg = true; // MVP = Projection * View * Model。 // 这里 panel/dots 顶点基本已经是“世界坐标”,所以我们用 proj*view 即可(model 先省略)。 @@ -140,11 +170,12 @@ private: float m_modelPanel[16]{}; float m_zoom_ = 45.0; - float m_camYawDeg = 45.0f; - float m_camPitchDeg = 35.0f; + float m_camYawDeg = -90.0f; + float m_camPitchDeg = 0.0f; std::atomic m_rightDown{false}; QPoint m_lastPos; + }; diff --git a/src/nice_ticks.h b/src/nice_ticks.h new file mode 100644 index 0000000..a7b8998 --- /dev/null +++ b/src/nice_ticks.h @@ -0,0 +1,53 @@ +#ifndef NICE_TICKS +#define NICE_TICKS + +#include +#include + +inline double niceNumber(double x, bool round) { + const double expv = std::floor(std::log10(x)); + const double f = x / std::pow(10.0, expv); + double nf; + if (round) { + if (f < 1.5) nf = 1.0; + else if (f < 3.0) nf = 2.0; + else if (f < 7.0) nf = 5.0; + else nf = 10.0; + } else { + if (f <= 1.0) nf = 1.0; + else if (f <= 2.0) nf = 2.0; + else if (f <= 5.0) nf = 5.0; + else nf = 10.0; + } + return nf * std::pow(10.0, expv); +} + +struct NiceTicksResult { + double niceMin = 0; + double niceMax = 1; + double step = 0.2; + std::vector ticks; +}; + +inline NiceTicksResult niceTicks(double minv, double maxv, int maxTicks = 5) { + NiceTicksResult r; + if (minv == maxv) { + // 给一个小范围 + minv -= 1.0; + maxv += 1.0; + } + const double range = niceNumber(maxv - minv, false); + const double step = niceNumber(range / (maxTicks - 1), true); + const double niceMin = std::floor(minv / step) * step; + const double niceMax = std::ceil(maxv / step) * step; + + r.niceMin = niceMin; + r.niceMax = niceMax; + r.step = step; + + for (double v = niceMin; v <= niceMax + 0.5 * step; v += step) { + r.ticks.push_back(v); + } + return r; +} +#endif \ No newline at end of file diff --git a/src/ringbuffer.cpp b/src/ringbuffer.cpp new file mode 100644 index 0000000..e69de29 diff --git a/src/ringbuffer.h b/src/ringbuffer.h new file mode 100644 index 0000000..ea319e4 --- /dev/null +++ b/src/ringbuffer.h @@ -0,0 +1,82 @@ +#ifndef RINGBUFFER_H +#define RINGBUFFER_H + +#include +#include +#include +#include +#include +#include +#include + +template +class RingBuffer { + +public: + explicit RingBuffer(size_t capacity, T initValue = T{}) + : m_capacity(capacity) + , m_data(capacity, initValue) {} + + inline void push(const T& v) { + const uint64_t w = m_write.fetch_add(1, std::memory_order_relaxed); + m_data[w % m_capacity] = v; + const uint64_t newSize = std::min(w + 1, m_capacity); + m_size.store(newSize, std::memory_order_release); + } + + inline uint64_t size() const { + return m_size.load(std::memory_order_acquire); + } + + inline uint64_t capacity() const { + return m_capacity; + } + + inline uint64_t oldestGlobalIndex() const { + const uint64_t w = m_write.load(std::memory_order_acquire); + const uint64_t s = size(); + return (w >= s) ? (w - s) : 0; + } + + inline uint64_t newestGlobalIndex() const { + const uint64_t w = m_write.load(std::memory_order_acquire); + return (w > 0) ? (w - 1) : 0; + } + + inline bool readByGlobalIndex(uint64_t gidx, T& out) const { + const uint64_t oldest = oldestGlobalIndex(); + const uint64_t newest = newestGlobalIndex(); + if (gidx < oldest || gidx > newest) + return false; + + out = m_data[gidx % m_capacity]; + return true; + } + + inline void reset() { + m_write.store(0, std::memory_order_release); + m_size.store(0, std::memory_order_release); + } + + inline void readRange(uint64_t gidx, uint64_t count, std::vector& out) const { + out.clear(); + out.reserve(count); + for (size_t i = 0; i < count; i++) { + T v{}; + if (readByGlobalIndex(gidx, v)) { + out.push_back(v); + } + else { + + } + } + } + +private: + uint64_t m_capacity; + std::vector m_data; + std::atomic m_write{0}; + std::atomic m_size{0}; +}; + +#endif \ No newline at end of file diff --git a/src/serial/piezoresistive_a_protocol.cpp b/src/serial/piezoresistive_a_protocol.cpp new file mode 100644 index 0000000..411a102 --- /dev/null +++ b/src/serial/piezoresistive_a_protocol.cpp @@ -0,0 +1,241 @@ +#include "piezoresistive_a_protocol.h" + +#include +#include +#include + +namespace { +constexpr quint8 kReplyStart0 = 0x55; // 0x55AA little-endian +constexpr quint8 kReplyStart1 = 0xAA; +constexpr quint8 kReplyStartAlt0 = 0xAA; +constexpr quint8 kReplyStartAlt1 = 0x55; +constexpr quint8 kRequestStart0 = 0x55; // 0x55AA little-endian +constexpr quint8 kRequestStart1 = 0xAA; + +quint16 readLe16(const QByteArray& data, int offset) { + const quint8 b0 = static_cast(data[offset]); + const quint8 b1 = static_cast(data[offset + 1]); + return static_cast(b0 | (b1 << 8)); +} + +quint32 readLe32(const QByteArray& data, int offset) { + const quint8 b0 = static_cast(data[offset]); + const quint8 b1 = static_cast(data[offset + 1]); + const quint8 b2 = static_cast(data[offset + 2]); + const quint8 b3 = static_cast(data[offset + 3]); + return static_cast(b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)); +} + +void appendLe16(QByteArray& data, quint16 v) { + data.append(static_cast(v & 0xFF)); + data.append(static_cast((v >> 8) & 0xFF)); +} + +void appendLe32(QByteArray& data, quint32 v) { + data.append(static_cast(v & 0xFF)); + data.append(static_cast((v >> 8) & 0xFF)); + data.append(static_cast((v >> 16) & 0xFF)); + data.append(static_cast((v >> 24) & 0xFF)); +} +} + +quint8 PiezoresistiveAFormat::crc8ITU(const QByteArray& data, int length) { + quint8 crc = 0x00; + const int limit = qMin(length, data.size()); + for (int i = 0; i < limit; ++i) { + crc ^= static_cast(data[i]); + for (int bit = 0; bit < 8; ++bit) { + if (crc & 0x80) + crc = static_cast((crc << 1) ^ 0x07); + else + crc = static_cast(crc << 1); + } + } + return static_cast(crc ^ 0x55); +} + +ISerialFormat::ParseResult PiezoresistiveAFormat::tryParse(QByteArray* buffer, QByteArray* packet, QString* error) { + if (!buffer || buffer->isEmpty()) + return ParseResult::NeedMore; + + int startIndex = -1; + for (int i = 0; i + 1 < buffer->size(); ++i) { + if (static_cast((*buffer)[i]) == kReplyStart0 && + static_cast((*buffer)[i + 1]) == kReplyStart1) { + startIndex = i; + break; + } + if (static_cast((*buffer)[i]) == kReplyStartAlt0 && + static_cast((*buffer)[i + 1]) == kReplyStartAlt1) { + startIndex = i; + break; + } + } + + if (startIndex < 0) { + const quint8 tail = static_cast(buffer->back()); + buffer->clear(); + if (tail == kReplyStart0 || tail == kReplyStart1) + buffer->append(static_cast(tail)); + return ParseResult::NeedMore; + } + + if (startIndex > 0) + buffer->remove(0, startIndex); + + if (buffer->size() < 5) + return ParseResult::NeedMore; + + const quint16 payloadLen = readLe16(*buffer, 2); + const int totalLen = 4 + payloadLen + 1; + if (buffer->size() < totalLen) + return ParseResult::NeedMore; + + QByteArray candidate = buffer->left(totalLen); + buffer->remove(0, totalLen); + + const quint8 crc = static_cast(candidate.at(candidate.size() - 1)); + const quint8 calc = crc8ITU(candidate, candidate.size() - 1); + if (crc != calc) { + if (error) + *error = QStringLiteral("CRC mismatch"); + return ParseResult::Invalid; + } + + if (packet) + *packet = candidate; + if (error) + error->clear(); + return ParseResult::Ok; +} + +QByteArray PiezoresistiveACodec::buildRequest(const SerialConfig& config, const SensorRequest& request) { + QByteArray packet; + packet.reserve(15); + + packet.append(static_cast(kRequestStart0)); + packet.append(static_cast(kRequestStart1)); + + const quint16 payloadLen = 9; + appendLe16(packet, payloadLen); + + packet.append(static_cast(config.deviceAddress)); + packet.append(static_cast(0x00)); + packet.append(static_cast(0x80 | request.functionCode)); + + appendLe32(packet, request.startAddress); + appendLe16(packet, request.dataLength); + + const quint8 crc = PiezoresistiveAFormat::crc8ITU(packet, packet.size()); + packet.append(static_cast(crc)); + + return packet; +} + +QByteArray PiezoresistiveACodec::buildGetVersionRequest(const SerialConfig& config) { + // TODO:待实现内容(压阻 A 型版本号查询请求帧) + Q_UNUSED(config) + return QByteArray(); +} + +QByteArray PiezoresistiveACodec::buildGetSpecRequest(const SerialConfig& config) { + // TODO:待实现内容(压阻 A 型规格查询请求帧) + Q_UNUSED(config) + return QByteArray(); +} + +bool PiezoresistiveADecoder::decodeFrame(const QByteArray& packet, DataFrame* frame, QString* error) { + if (!frame) { + if (error) + *error = QStringLiteral("Null frame output"); + return false; + } + + if (packet.size() < 15) { + if (error) + *error = QStringLiteral("Packet too short"); + return false; + } + + const quint8 start0 = static_cast(packet[0]); + const quint8 start1 = static_cast(packet[1]); + const bool startOk = (start0 == kReplyStart0 && start1 == kReplyStart1) || + (start0 == kReplyStartAlt0 && start1 == kReplyStartAlt1); + if (!startOk) { + if (error) + *error = QStringLiteral("Bad start bytes"); + return false; + } + + const quint16 payloadLen = readLe16(packet, 2); + const int totalLen = 4 + payloadLen + 1; + if (packet.size() != totalLen) { + if (error) + *error = QStringLiteral("Length mismatch"); + return false; + } + + const quint8 crc = static_cast(packet.at(packet.size() - 1)); + const quint8 calc = PiezoresistiveAFormat::crc8ITU(packet, packet.size() - 1); + if (crc != calc) { + if (error) + *error = QStringLiteral("CRC mismatch"); + return false; + } + + const quint8 funcRaw = static_cast(packet[6]); + const quint16 dataLen = readLe16(packet, 11); + const quint8 status = static_cast(packet[13]); + + if (payloadLen != static_cast(10 + dataLen)) { + if (error) + *error = QStringLiteral("Payload length mismatch"); + return false; + } + + if (status != 0) { + if (error) + *error = QStringLiteral("Device status error"); + return false; + } + + if (packet.size() < 15 + dataLen) { + if (error) + *error = QStringLiteral("Data length mismatch"); + return false; + } + + if ((dataLen % 2) != 0) { + if (error) + *error = QStringLiteral("Odd data length"); + return false; + } + + const int sampleCount = dataLen / 2; + QVector values; + values.reserve(sampleCount); + + const int dataOffset = 14; + for (int i = 0; i < sampleCount; ++i) { + const int offset = dataOffset + i * 2; + const quint16 raw = readLe16(packet, offset); + values.push_back(static_cast(raw)); + } + + frame->pts = DataFrame::makePts(QDateTime::currentDateTime()); + frame->functionCode = (funcRaw >= 0x80) ? static_cast(funcRaw - 0x80) : funcRaw; + frame->data = values; + + if (error) + error->clear(); + return true; +} + +bool PiezoresistiveADecoder::decodeSpec(const QByteArray& packet, SensorSpec* spec, QString* error) { + // TODO:待实现内容(解析压阻 A 型规格回复并写入 SensorSpec) + Q_UNUSED(packet) + Q_UNUSED(spec) + if (error) + *error = QStringLiteral("Not implemented"); + return false; +} diff --git a/src/serial/piezoresistive_a_protocol.h b/src/serial/piezoresistive_a_protocol.h new file mode 100644 index 0000000..34a835b --- /dev/null +++ b/src/serial/piezoresistive_a_protocol.h @@ -0,0 +1,32 @@ +#ifndef TACTILEIPC3D_PIEZORESISTIVE_A_PROTOCOL_H +#define TACTILEIPC3D_PIEZORESISTIVE_A_PROTOCOL_H + +#include "serial_codec.h" +#include "serial_decoder.h" +#include "serial_format.h" + +class PiezoresistiveAFormat : public ISerialFormat { +public: + ParseResult tryParse(QByteArray* buffer, QByteArray* packet, QString* error) override; + + static quint8 crc8ITU(const QByteArray& data, int length); +}; + +class PiezoresistiveACodec : public ISerialCodec { +public: + QString name() const override { return QStringLiteral("piezoresistive_a"); } + + QByteArray buildRequest(const SerialConfig& config, const SensorRequest& request) override; + QByteArray buildGetVersionRequest(const SerialConfig& config) override; + QByteArray buildGetSpecRequest(const SerialConfig& config) override; +}; + +class PiezoresistiveADecoder : public ISerialDecoder { +public: + QString name() const override { return QStringLiteral("piezoresistive_a"); } + + bool decodeFrame(const QByteArray& packet, DataFrame* frame, QString* error) override; + bool decodeSpec(const QByteArray& packet, SensorSpec* spec, QString* error) override; +}; + +#endif // TACTILEIPC3D_PIEZORESISTIVE_A_PROTOCOL_H diff --git a/src/serial/serial_backend.cpp b/src/serial/serial_backend.cpp new file mode 100644 index 0000000..3d129bf --- /dev/null +++ b/src/serial/serial_backend.cpp @@ -0,0 +1,358 @@ +#include "serial_backend.h" + +#include "piezoresistive_a_protocol.h" +#include "serial_qt_transport.h" + +#include +#include +#include +#include +#include +#include + +SerialBackend::SerialBackend(QObject* parent) + : QObject(parent) + , m_packetQueue(2048) + , m_frameQueue(2048) + , m_readThread(&m_packetQueue) + , m_decodeThread(&m_packetQueue, &m_frameQueue) { + m_request.dataLength = 24; + m_spec.model = QStringLiteral("PZR-A"); + m_spec.rows = 3; + m_spec.cols = 4; + + auto codec = std::make_shared(); + auto decoder = std::make_shared(); + auto format = std::make_shared(); + m_manager.registerProtocol(codec->name(), {codec, decoder, format}); + m_manager.setActiveProtocol(codec->name()); + + m_sendWorker = new SerialSendWorker(); + m_sendWorker->moveToThread(&m_sendThread); + connect(m_sendWorker, &SerialSendWorker::bytesReceived, this, [this](const QByteArray& data) { +#if 0 + if (!data.isEmpty()) + qDebug().noquote() << "Serial recv bytes:" << QString::fromLatin1(data.toHex(' ')); +#endif + m_readThread.enqueueBytes(data); + }); + connect(m_sendWorker, &SerialSendWorker::requestBuilt, this, &SerialBackend::requestBuilt); + connect(m_sendWorker, &SerialSendWorker::writeFailed, this, [](const QString& error) { + if (!error.isEmpty()) + qWarning().noquote() << "Serial write failed:" << error; + }); + + connect(&m_readThread, &SerialReadThread::parseError, this, [](const QString& error) { + if (!error.isEmpty()) + qWarning().noquote() << "Serial packet invalid:" << error; + }); + connect(&m_decodeThread, &SerialDecodeThread::decodeError, this, [](const QString& error) { + if (!error.isEmpty()) + qWarning().noquote() << "Serial decode failed:" << error; + }); + connect(&m_decodeThread, &SerialDecodeThread::frameAvailable, this, &SerialBackend::drainFrames_); + + m_sendThread.start(); + setTransport(std::make_unique()); + updateProtocolBindings_(); + syncSendConfig_(); + syncSendRequest_(); + + refreshPorts(); +} + +SerialBackend::~SerialBackend() { + close(); + stopPipeline_(); + + if (m_sendWorker && m_sendThread.isRunning()) { + QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker]() { + worker->closeTransport(); + }, Qt::BlockingQueuedConnection); + } + + if (m_sendWorker && m_sendThread.isRunning()) { + QMetaObject::invokeMethod(m_sendWorker, &QObject::deleteLater, Qt::QueuedConnection); + } + + if (m_sendThread.isRunning()) { + m_sendThread.quit(); + m_sendThread.wait(); + } + + m_sendWorker = nullptr; +} + +QString SerialBackend::mode() const { + return (m_config.mode == DeviceMode::Slave) ? QStringLiteral("slave") : QStringLiteral("master"); +} + +QString SerialBackend::sensorGrid() const { + return QStringLiteral("%1x%2").arg(m_spec.rows).arg(m_spec.cols); +} + +void SerialBackend::setPortName(const QString& name) { + if (m_config.portName == name) + return; + m_config.portName = name; + syncSendConfig_(); + emit portNameChanged(); +} + +void SerialBackend::setBaudRate(int rate) { + if (m_config.baudRate == rate) + return; + m_config.baudRate = rate; + syncSendConfig_(); + emit baudRateChanged(); +} + +void SerialBackend::setPollIntervalMs(int intervalMs) { + intervalMs = qMax(1, intervalMs); + if (m_config.pollIntervalMs == intervalMs) + return; + m_config.pollIntervalMs = intervalMs; + syncSendConfig_(); + emit pollIntervalMsChanged(); +} + +void SerialBackend::setDeviceAddress(int address) { + const int capped = qBound(0, address, 255); + if (m_config.deviceAddress == static_cast(capped)) + return; + m_config.deviceAddress = static_cast(capped); + syncSendConfig_(); + emit deviceAddressChanged(); +} + +void SerialBackend::setMode(const QString& mode) { + const QString lower = mode.trimmed().toLower(); + const DeviceMode next = (lower == QStringLiteral("master")) ? DeviceMode::Master : DeviceMode::Slave; + if (m_config.mode == next) + return; + m_config.mode = next; + syncSendConfig_(); + emit modeChanged(); +} + +void SerialBackend::setRequestFunction(int func) { + const int capped = qBound(0, func, 255); + if (m_request.functionCode == static_cast(capped)) + return; + m_request.functionCode = static_cast(capped); + syncSendRequest_(); + emit requestFunctionChanged(); +} + +void SerialBackend::setRequestStartAddress(int addr) { + const quint32 capped = static_cast(qMax(0, addr)); + if (m_request.startAddress == capped) + return; + m_request.startAddress = capped; + syncSendRequest_(); + emit requestStartAddressChanged(); +} + +void SerialBackend::setRequestLength(int len) { + const int capped = qBound(0, len, 65535); + if (m_request.dataLength == static_cast(capped)) + return; + m_request.dataLength = static_cast(capped); + syncSendRequest_(); + emit requestLengthChanged(); +} + +void SerialBackend::setProtocol(const QString& name) { + if (!m_manager.setActiveProtocol(name)) + return; + updateProtocolBindings_(); + emit protocolChanged(); +} + +void SerialBackend::applySensorSpec(const QString& model, int rows, int cols) { + const QString nextModel = model.trimmed(); + const int nextRows = qMax(0, rows); + const int nextCols = qMax(0, cols); + bool changed = false; + + if (!nextModel.isEmpty() && m_spec.model != nextModel) { + m_spec.model = nextModel; + emit sensorModelChanged(); + changed = true; + } + + if (m_spec.rows != nextRows || m_spec.cols != nextCols) { + m_spec.rows = nextRows; + m_spec.cols = nextCols; + emit sensorGridChanged(); + changed = true; + } + + if (!changed) + return; +} + +void SerialBackend::setTransport(std::unique_ptr transport) { + if (!transport || !m_sendWorker) + return; + if (!m_sendThread.isRunning()) + m_sendThread.start(); + if (transport->thread() != &m_sendThread) + transport->moveToThread(&m_sendThread); + QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, transport = std::move(transport)]() mutable { + worker->setTransport(std::move(transport)); + }, Qt::BlockingQueuedConnection); +} + +void SerialBackend::refreshPorts() { + // Placeholder for real port discovery (QtSerialPort or third-party transport). + + m_availablePorts.clear(); + auto device_found = QSerialPortInfo::availablePorts(); + for (auto item : device_found) { + m_availablePorts.append(item.portName()); + } + if (m_config.portName.isEmpty() && !m_availablePorts.isEmpty()) { + m_config.portName = m_availablePorts.first(); + emit portNameChanged(); + } + emit availablePortsChanged(); +} + +bool SerialBackend::open() { + if (m_connected) + return true; + + startPipeline_(); + + bool ok = false; + QString error; + if (m_sendWorker) { + QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, config = m_config, &ok, &error]() { + ok = worker->openTransport(config, &error); + }, Qt::BlockingQueuedConnection); + } + if (!ok) { + qWarning().noquote() << "Serial open failed:" << error; + return false; + } + + m_connected = true; + emit connectedChanged(); + + return true; +} + +void SerialBackend::close() { + if (!m_connected) + return; + if (m_sendWorker && m_sendThread.isRunning()) { + QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker]() { + worker->closeTransport(); + }, Qt::BlockingQueuedConnection); + } + stopPipeline_(); + m_connected = false; + emit connectedChanged(); +} + +void SerialBackend::requestOnce() { + if (!m_sendWorker) + return; + QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker]() { + worker->requestOnce(); + }, Qt::QueuedConnection); +} + +void SerialBackend::feedBytes(const QByteArray& data) { + if (!m_readThread.isRunning()) + startPipeline_(); + m_readThread.enqueueBytes(data); +} + +void SerialBackend::drainFrames_() { + if (!m_frameCallback) + return; + DataFrame frame; + while (m_frameQueue.tryPop(&frame)) + m_frameCallback(frame); +} + +void SerialBackend::startPipeline_() { + if (m_readThread.isRunning() || m_decodeThread.isRunning()) + stopPipeline_(); + m_packetQueue.reset(); + m_frameQueue.reset(); + m_readThread.clear(); + updateProtocolBindings_(); + + if (!m_readThread.isRunning()) + m_readThread.start(); + if (!m_decodeThread.isRunning()) + m_decodeThread.start(); +} + +void SerialBackend::stopPipeline_() { + if (m_readThread.isRunning()) { + m_readThread.stop(); + m_readThread.wait(); + } + if (m_decodeThread.isRunning()) { + m_decodeThread.stop(); + m_decodeThread.wait(); + } + m_packetQueue.reset(); + m_frameQueue.reset(); + m_readThread.clear(); +} + +void SerialBackend::updateProtocolBindings_() { + const auto bundle = m_manager.activeBundle(); + + SerialReadThread::ParseFunc parseFunc; + if (bundle.format) { + parseFunc = [format = bundle.format](QByteArray* buffer, QByteArray* packet, QString* error) { + return format->tryParse(buffer, packet, error); + }; + } + m_readThread.setParseFunc(std::move(parseFunc)); + + SerialDecodeThread::DecodeFunc decodeFunc; + if (bundle.decoder) { + decodeFunc = [decoder = bundle.decoder](const QByteArray& packet, DataFrame* frame, QString* error) { + return decoder->decodeFrame(packet, frame, error); + }; + } + m_decodeThread.setDecodeFunc(std::move(decodeFunc)); + + SerialSendWorker::BuildRequestFunc requestFunc; + if (bundle.codec) { + requestFunc = [codec = bundle.codec](const SerialConfig& config, const SensorRequest& request) { + return codec->buildRequest(config, request); + }; + } + + if (m_sendWorker) { + QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, requestFunc]() mutable { + worker->setBuildRequestFunc(std::move(requestFunc)); + }, Qt::QueuedConnection); + } +} + +void SerialBackend::syncSendConfig_() { + if (!m_sendWorker) + return; + const SerialConfig config = m_config; + QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, config]() { + worker->setConfig(config); + }, Qt::QueuedConnection); +} + +void SerialBackend::syncSendRequest_() { + if (!m_sendWorker) + return; + const SensorRequest request = m_request; + QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, request]() { + worker->setRequest(request); + }, Qt::QueuedConnection); +} diff --git a/src/serial/serial_backend.h b/src/serial/serial_backend.h new file mode 100644 index 0000000..9b60f24 --- /dev/null +++ b/src/serial/serial_backend.h @@ -0,0 +1,114 @@ +#ifndef TACTILEIPC3D_SERIAL_BACKEND_H +#define TACTILEIPC3D_SERIAL_BACKEND_H + +#include +#include +#include +#include +#include +#include "serial_manager.h" +#include "serial_threads.h" +#include "../data_frame.h" + +class SerialBackend : public QObject { + Q_OBJECT + Q_PROPERTY(QString portName READ portName WRITE setPortName NOTIFY portNameChanged) + Q_PROPERTY(int baudRate READ baudRate WRITE setBaudRate NOTIFY baudRateChanged) + Q_PROPERTY(int pollIntervalMs READ pollIntervalMs WRITE setPollIntervalMs NOTIFY pollIntervalMsChanged) + Q_PROPERTY(int deviceAddress READ deviceAddress WRITE setDeviceAddress NOTIFY deviceAddressChanged) + Q_PROPERTY(QString mode READ mode WRITE setMode NOTIFY modeChanged) + Q_PROPERTY(int requestFunction READ requestFunction WRITE setRequestFunction NOTIFY requestFunctionChanged) + Q_PROPERTY(int requestStartAddress READ requestStartAddress WRITE setRequestStartAddress NOTIFY requestStartAddressChanged) + Q_PROPERTY(int requestLength READ requestLength WRITE setRequestLength NOTIFY requestLengthChanged) + Q_PROPERTY(QString protocol READ protocol WRITE setProtocol NOTIFY protocolChanged) + Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) + Q_PROPERTY(QStringList availablePorts READ availablePorts NOTIFY availablePortsChanged) + Q_PROPERTY(QString sensorModel READ sensorModel NOTIFY sensorModelChanged) + Q_PROPERTY(QString sensorGrid READ sensorGrid NOTIFY sensorGridChanged) + +public: + using FrameCallback = std::function; + + explicit SerialBackend(QObject* parent = nullptr); + ~SerialBackend() override; + + QString portName() const { return m_config.portName; } + int baudRate() const { return m_config.baudRate; } + int pollIntervalMs() const { return m_config.pollIntervalMs; } + int deviceAddress() const { return static_cast(m_config.deviceAddress); } + QString mode() const; + + int requestFunction() const { return m_request.functionCode; } + int requestStartAddress() const { return static_cast(m_request.startAddress); } + int requestLength() const { return m_request.dataLength; } + + QString protocol() const { return m_manager.activeProtocol(); } + bool connected() const { return m_connected; } + QStringList availablePorts() const { return m_availablePorts; } + QString sensorModel() const { return m_spec.model; } + QString sensorGrid() const; + + void setPortName(const QString& name); + void setBaudRate(int rate); + void setPollIntervalMs(int intervalMs); + void setDeviceAddress(int address); + void setMode(const QString& mode); + + void setRequestFunction(int func); + void setRequestStartAddress(int addr); + void setRequestLength(int len); + void setProtocol(const QString& name); + Q_INVOKABLE void applySensorSpec(const QString& model, int rows, int cols); + + void setFrameCallback(FrameCallback cb) { m_frameCallback = std::move(cb); } + void setTransport(std::unique_ptr transport); + + Q_INVOKABLE void refreshPorts(); + Q_INVOKABLE bool open(); + Q_INVOKABLE void close(); + Q_INVOKABLE void requestOnce(); + Q_INVOKABLE void feedBytes(const QByteArray& data); + +signals: + void portNameChanged(); + void baudRateChanged(); + void pollIntervalMsChanged(); + void deviceAddressChanged(); + void modeChanged(); + void requestFunctionChanged(); + void requestStartAddressChanged(); + void requestLengthChanged(); + void protocolChanged(); + void connectedChanged(); + void availablePortsChanged(); + void sensorModelChanged(); + void sensorGridChanged(); + + void requestBuilt(const QByteArray& data); + +private: + void drainFrames_(); + void startPipeline_(); + void stopPipeline_(); + void updateProtocolBindings_(); + void syncSendConfig_(); + void syncSendRequest_(); + + SerialConfig m_config; + SensorRequest m_request; + SensorSpec m_spec; + SerialManager m_manager; + PacketQueue m_packetQueue; + FrameQueue m_frameQueue; + SerialReadThread m_readThread; + SerialDecodeThread m_decodeThread; + QThread m_sendThread; + SerialSendWorker* m_sendWorker = nullptr; + + FrameCallback m_frameCallback; + bool m_connected = false; + + QStringList m_availablePorts; +}; + +#endif // TACTILEIPC3D_SERIAL_BACKEND_H diff --git a/src/serial/serial_codec.h b/src/serial/serial_codec.h new file mode 100644 index 0000000..607070a --- /dev/null +++ b/src/serial/serial_codec.h @@ -0,0 +1,23 @@ +#ifndef TACTILEIPC3D_SERIAL_CODEC_H +#define TACTILEIPC3D_SERIAL_CODEC_H + +#include +#include + +#include "serial_types.h" + +class ISerialCodec { +public: + virtual ~ISerialCodec() = default; + virtual QString name() const = 0; + + virtual QByteArray buildRequest(const SerialConfig& config, const SensorRequest& request) = 0; + + // Reserved hooks for later expansion + // TODO:待实现内容(构建获取版本号的请求帧) + virtual QByteArray buildGetVersionRequest(const SerialConfig& config) = 0; + // TODO:待实现内容(构建获取传感器规格的请求帧) + virtual QByteArray buildGetSpecRequest(const SerialConfig& config) = 0; +}; + +#endif // TACTILEIPC3D_SERIAL_CODEC_H diff --git a/src/serial/serial_decoder.h b/src/serial/serial_decoder.h new file mode 100644 index 0000000..4849a5f --- /dev/null +++ b/src/serial/serial_decoder.h @@ -0,0 +1,22 @@ +#ifndef TACTILEIPC3D_SERIAL_DECODER_H +#define TACTILEIPC3D_SERIAL_DECODER_H + +#include +#include + +#include "serial_types.h" +#include "../data_frame.h" + +class ISerialDecoder { +public: + virtual ~ISerialDecoder() = default; + virtual QString name() const = 0; + + virtual bool decodeFrame(const QByteArray& packet, DataFrame* frame, QString* error) = 0; + + // Reserved hooks for later expansion + // TODO:待实现内容(解析规格回复帧并填充 SensorSpec) + virtual bool decodeSpec(const QByteArray& packet, SensorSpec* spec, QString* error) = 0; +}; + +#endif // TACTILEIPC3D_SERIAL_DECODER_H diff --git a/src/serial/serial_format.h b/src/serial/serial_format.h new file mode 100644 index 0000000..963b215 --- /dev/null +++ b/src/serial/serial_format.h @@ -0,0 +1,19 @@ +#ifndef TACTILEIPC3D_SERIAL_FORMAT_H +#define TACTILEIPC3D_SERIAL_FORMAT_H + +#include +#include + +class ISerialFormat { +public: + enum class ParseResult { + NeedMore = 0, + Ok = 1, + Invalid = 2, + }; + + virtual ~ISerialFormat() = default; + virtual ParseResult tryParse(QByteArray* buffer, QByteArray* packet, QString* error) = 0; +}; + +#endif // TACTILEIPC3D_SERIAL_FORMAT_H diff --git a/src/serial/serial_manager.cpp b/src/serial/serial_manager.cpp new file mode 100644 index 0000000..53ea384 --- /dev/null +++ b/src/serial/serial_manager.cpp @@ -0,0 +1,22 @@ +#include "serial_manager.h" + +void SerialManager::registerProtocol(const QString& name, const ProtocolBundle& bundle) { + if (name.isEmpty()) + return; + m_protocols.insert(name, bundle); + if (m_activeName.isEmpty()) + m_activeName = name; +} + +bool SerialManager::setActiveProtocol(const QString& name) { + if (!m_protocols.contains(name)) + return false; + m_activeName = name; + return true; +} + +SerialManager::ProtocolBundle SerialManager::activeBundle() const { + if (!m_protocols.contains(m_activeName)) + return {}; + return m_protocols.value(m_activeName); +} diff --git a/src/serial/serial_manager.h b/src/serial/serial_manager.h new file mode 100644 index 0000000..e8a8f8c --- /dev/null +++ b/src/serial/serial_manager.h @@ -0,0 +1,31 @@ +#ifndef TACTILEIPC3D_SERIAL_MANAGER_H +#define TACTILEIPC3D_SERIAL_MANAGER_H + +#include +#include +#include + +#include "serial_codec.h" +#include "serial_decoder.h" +#include "serial_format.h" + +class SerialManager { +public: + struct ProtocolBundle { + std::shared_ptr codec; + std::shared_ptr decoder; + std::shared_ptr format; + }; + + void registerProtocol(const QString& name, const ProtocolBundle& bundle); + bool setActiveProtocol(const QString& name); + + QString activeProtocol() const { return m_activeName; } + ProtocolBundle activeBundle() const; + +private: + QHash m_protocols; + QString m_activeName; +}; + +#endif // TACTILEIPC3D_SERIAL_MANAGER_H diff --git a/src/serial/serial_qt_transport.cpp b/src/serial/serial_qt_transport.cpp new file mode 100644 index 0000000..4e30feb --- /dev/null +++ b/src/serial/serial_qt_transport.cpp @@ -0,0 +1,140 @@ +#include "serial_qt_transport.h" +#include "serial/serial_types.h" + +#include +#include +#include + +namespace { +QSerialPort::DataBits mapDataBits(int bits) { + switch (bits) { + case 5: + return QSerialPort::Data5; + case 6: + return QSerialPort::Data6; + case 7: + return QSerialPort::Data7; + case 8: + default: + return QSerialPort::Data8; + } +} + +QSerialPort::StopBits mapStopBits(int bits) { + switch (bits) { + case 2: + return QSerialPort::TwoStop; + case 1: + default: + return QSerialPort::OneStop; + } +} + +QSerialPort::Parity mapParity(const QString& parity) { + const QString key = parity.trimmed().toUpper(); + if (key == QStringLiteral("E") || key == QStringLiteral("EVEN")) + return QSerialPort::EvenParity; + if (key == QStringLiteral("O") || key == QStringLiteral("ODD")) + return QSerialPort::OddParity; + if (key == QStringLiteral("M") || key == QStringLiteral("MARK")) + return QSerialPort::MarkParity; + if (key == QStringLiteral("S") || key == QStringLiteral("SPACE")) + return QSerialPort::SpaceParity; + return QSerialPort::NoParity; +} +} // namespace + +QtSerialTransport::QtSerialTransport(QObject* parent) + : ISerialTransport(parent) {} + +QtSerialTransport::~QtSerialTransport() { + close(); +} + +void QtSerialTransport::ensurePort_() { + if (m_port) + return; + + m_port = new QSerialPort(this); + connect(m_port, &QSerialPort::readyRead, this, [this]() { + const QByteArray data = m_port->readAll(); + if (!data.isEmpty()) + emit bytesReceived(data); + }); +} + +void applyConfigDebug(const SerialConfig& config) { +#if DEBUG_MODE + QString strd = ""; + strd += "baud:" + QString::number(config.baudRate); +#endif +} + +void QtSerialTransport::applyConfig_(const SerialConfig& config) { + if (!m_port) + return; + applyConfigDebug(config); + m_port->setBaudRate(config.baudRate); + m_port->setDataBits(mapDataBits(config.dataBits)); + m_port->setStopBits(mapStopBits(config.stopBits)); + m_port->setParity(mapParity(config.parity)); + // TODO:待实现内容(根据SerialConfig扩展软件/硬件流控配置) + m_port->setFlowControl(QSerialPort::NoFlowControl); +} + +bool QtSerialTransport::open(const SerialConfig& config, QString* error) { + ensurePort_(); + + if (config.portName.trimmed().isEmpty()) { + if (error) + *error = QStringLiteral("Port name is empty"); + return false; + } + + if (m_port->isOpen()) + m_port->close(); + + m_port->setPortName(config.portName); + applyConfig_(config); + + const bool ok = m_port->open(QIODevice::ReadWrite); + if (!ok) { + if (error) + *error = m_port->errorString(); + return false; + } + + if (error) + error->clear(); + return true; +} + +void QtSerialTransport::close() { + if (m_port && m_port->isOpen()) + m_port->close(); +} + +bool QtSerialTransport::writeBytes(const QByteArray& data, QString* error) { + if (!m_port || !m_port->isOpen()) { + if (error) + *error = QStringLiteral("Serial port not open"); + return false; + } + + if (data.isEmpty()) { + if (error) + error->clear(); + return true; + } + + const qint64 written = m_port->write(data); + if (written < 0) { + if (error) + *error = m_port->errorString(); + return false; + } + + if (error) + error->clear(); + return true; +} diff --git a/src/serial/serial_qt_transport.h b/src/serial/serial_qt_transport.h new file mode 100644 index 0000000..db77e9f --- /dev/null +++ b/src/serial/serial_qt_transport.h @@ -0,0 +1,25 @@ +#ifndef TACTILEIPC3D_SERIAL_QT_TRANSPORT_H +#define TACTILEIPC3D_SERIAL_QT_TRANSPORT_H + +#include "serial_transport.h" + +class QSerialPort; + +class QtSerialTransport : public ISerialTransport { + Q_OBJECT +public: + explicit QtSerialTransport(QObject* parent = nullptr); + ~QtSerialTransport() override; + + bool open(const SerialConfig& config, QString* error) override; + void close() override; + bool writeBytes(const QByteArray& data, QString* error) override; + +private: + void ensurePort_(); + void applyConfig_(const SerialConfig& config); + + QSerialPort* m_port = nullptr; +}; + +#endif // TACTILEIPC3D_SERIAL_QT_TRANSPORT_H diff --git a/src/serial/serial_queue.h b/src/serial/serial_queue.h new file mode 100644 index 0000000..a94733c --- /dev/null +++ b/src/serial/serial_queue.h @@ -0,0 +1,97 @@ +#ifndef TACTILEIPC3D_SERIAL_QUEUE_H +#define TACTILEIPC3D_SERIAL_QUEUE_H + +#include +#include +#include +#include +#include + +#include "../data_frame.h" + +template +class SerialQueue { +public: + explicit SerialQueue(int maxSize = 2048) + : m_maxSize(maxSize) {} + + void setMaxSize(int maxSize) { + QMutexLocker locker(&m_mutex); + m_maxSize = qMax(1, maxSize); + } + + void push(const T& item) { + QMutexLocker locker(&m_mutex); + if (m_stopped) + return; + if (m_maxSize > 0 && m_queue.size() >= m_maxSize) { + // TODO:待实现内容(指定队列溢出策略,例如丢弃最新/丢弃最旧/阻塞等待) + m_queue.dequeue(); + } + m_queue.enqueue(item); + m_hasData.wakeOne(); + } + + bool pop(T* out, int timeoutMs = -1) { + QMutexLocker locker(&m_mutex); + if (timeoutMs < 0) { + while (m_queue.isEmpty() && !m_stopped) + m_hasData.wait(&m_mutex); + } else { + if (m_queue.isEmpty() && !m_stopped) + m_hasData.wait(&m_mutex, timeoutMs); + } + if (m_queue.isEmpty() || m_stopped) + return false; + if (out) + *out = m_queue.dequeue(); + else + m_queue.dequeue(); + return true; + } + + bool tryPop(T* out) { + QMutexLocker locker(&m_mutex); + if (m_queue.isEmpty() || m_stopped) + return false; + if (out) + *out = m_queue.dequeue(); + else + m_queue.dequeue(); + return true; + } + + void clear() { + QMutexLocker locker(&m_mutex); + m_queue.clear(); + } + + void stop() { + QMutexLocker locker(&m_mutex); + m_stopped = true; + m_hasData.wakeAll(); + } + + void reset() { + QMutexLocker locker(&m_mutex); + m_stopped = false; + m_queue.clear(); + } + + int size() const { + QMutexLocker locker(&m_mutex); + return m_queue.size(); + } + +private: + mutable QMutex m_mutex; + QWaitCondition m_hasData; + QQueue m_queue; + int m_maxSize = 0; + bool m_stopped = false; +}; + +using PacketQueue = SerialQueue; +using FrameQueue = SerialQueue; + +#endif // TACTILEIPC3D_SERIAL_QUEUE_H diff --git a/src/serial/serial_threads.cpp b/src/serial/serial_threads.cpp new file mode 100644 index 0000000..b8e2bbd --- /dev/null +++ b/src/serial/serial_threads.cpp @@ -0,0 +1,216 @@ +#include "serial_threads.h" + +#include +#include + +SerialReadThread::SerialReadThread(PacketQueue* packetQueue, QObject* parent) + : QThread(parent) + , m_packetQueue(packetQueue) {} + +void SerialReadThread::setParseFunc(ParseFunc func) { + QMutexLocker locker(&m_funcMutex); + m_parseFunc = std::move(func); +} + +void SerialReadThread::enqueueBytes(const QByteArray& data) { + if (data.isEmpty()) + return; + QMutexLocker locker(&m_queueMutex); + m_byteQueue.enqueue(data); + m_dataReady.wakeOne(); +} + +void SerialReadThread::clear() { + QMutexLocker locker(&m_queueMutex); + m_byteQueue.clear(); + m_buffer.clear(); +} + +void SerialReadThread::stop() { + m_running = false; + m_dataReady.wakeAll(); +} + +void SerialReadThread::run() { + m_running = true; + while (m_running) { + QByteArray chunk; + { + QMutexLocker locker(&m_queueMutex); + while (m_byteQueue.isEmpty() && m_running) + m_dataReady.wait(&m_queueMutex); + if (!m_running) + break; + if (!m_byteQueue.isEmpty()) + chunk = m_byteQueue.dequeue(); + } + + if (chunk.isEmpty()) + continue; + + m_buffer.append(chunk); + + ParseFunc parseFunc; + { + QMutexLocker locker(&m_funcMutex); + parseFunc = m_parseFunc; + } + + if (!parseFunc) + continue; + + while (m_running) { + QByteArray packet; + QString error; + const ISerialFormat::ParseResult result = parseFunc(&m_buffer, &packet, &error); + if (result == ISerialFormat::ParseResult::NeedMore) + break; + if (result == ISerialFormat::ParseResult::Invalid) { + emit parseError(error); + continue; + } + if (!packet.isEmpty()) + qDebug().noquote() << "Serial packet rawdata:" << QString::fromLatin1(packet.toHex(' ')); + if (m_packetQueue) + m_packetQueue->push(packet); + } + } +} + +SerialDecodeThread::SerialDecodeThread(PacketQueue* packetQueue, FrameQueue* frameQueue, QObject* parent) + : QThread(parent) + , m_packetQueue(packetQueue) + , m_frameQueue(frameQueue) {} + +void SerialDecodeThread::setDecodeFunc(DecodeFunc func) { + QMutexLocker locker(&m_funcMutex); + m_decodeFunc = std::move(func); +} + +void SerialDecodeThread::stop() { + m_running = false; + if (m_packetQueue) + m_packetQueue->stop(); +} + +void SerialDecodeThread::run() { + m_running = true; + while (m_running) { + QByteArray packet; + if (!m_packetQueue || !m_packetQueue->pop(&packet)) + break; + + DecodeFunc decodeFunc; + { + QMutexLocker locker(&m_funcMutex); + decodeFunc = m_decodeFunc; + } + if (!decodeFunc) + continue; + + DataFrame frame; + QString error; + if (!decodeFunc(packet, &frame, &error)) { + emit decodeError(error); + continue; + } + if (m_frameQueue) + m_frameQueue->push(frame); + emit frameAvailable(); + } +} + +SerialSendWorker::SerialSendWorker(QObject* parent) + : QObject(parent) { + m_pollTimer.setParent(this); + m_pollTimer.setTimerType(Qt::PreciseTimer); + connect(&m_pollTimer, &QTimer::timeout, this, [this]() { + sendRequest_(); + }); +} + +void SerialSendWorker::setTransport(std::unique_ptr transport) { + if (m_transport) + m_transport->disconnect(this); + m_transport = std::move(transport); + if (m_transport) { + connect(m_transport.get(), &ISerialTransport::bytesReceived, + this, &SerialSendWorker::bytesReceived); + } +} + +void SerialSendWorker::setBuildRequestFunc(BuildRequestFunc func) { + m_buildRequest = std::move(func); +} + +void SerialSendWorker::setConfig(const SerialConfig& config) { + m_config = config; + updatePolling_(); +} + +void SerialSendWorker::setRequest(const SensorRequest& request) { + m_request = request; +} + +bool SerialSendWorker::openTransport(const SerialConfig& config, QString* error) { + m_config = config; + if (!m_transport) { + if (error) + *error = QStringLiteral("Transport missing"); + return false; + } + if (m_connected) { + if (error) + error->clear(); + updatePolling_(); + return true; + } + + QString localError; + const bool ok = m_transport->open(m_config, &localError); + m_connected = ok; + updatePolling_(); + if (error) + *error = localError; + return ok; +} + +void SerialSendWorker::closeTransport() { + if (!m_connected) + return; + m_pollTimer.stop(); + if (m_transport) + m_transport->close(); + m_connected = false; +} + +void SerialSendWorker::requestOnce() { + sendRequest_(); +} + +void SerialSendWorker::updatePolling_() { + if (!m_connected || m_config.mode != DeviceMode::Slave) { + m_pollTimer.stop(); + return; + } + const int interval = qMax(1, m_config.pollIntervalMs); + if (m_pollTimer.isActive()) + m_pollTimer.setInterval(interval); + else + m_pollTimer.start(interval); +} + +void SerialSendWorker::sendRequest_() { + if (!m_connected || !m_transport || !m_buildRequest) + return; + + const QByteArray request = m_buildRequest(m_config, m_request); + if (request.isEmpty()) + return; + + emit requestBuilt(request); + + QString error; + if (!m_transport->writeBytes(request, &error)) + emit writeFailed(error); +} diff --git a/src/serial/serial_threads.h b/src/serial/serial_threads.h new file mode 100644 index 0000000..4792724 --- /dev/null +++ b/src/serial/serial_threads.h @@ -0,0 +1,112 @@ +#ifndef TACTILEIPC3D_SERIAL_THREADS_H +#define TACTILEIPC3D_SERIAL_THREADS_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "serial_format.h" +#include "serial_transport.h" +#include "serial_queue.h" +#include "serial_types.h" + +class SerialReadThread : public QThread { + Q_OBJECT +public: + using ParseFunc = std::function; + + explicit SerialReadThread(PacketQueue* packetQueue, QObject* parent = nullptr); + + void setParseFunc(ParseFunc func); + void enqueueBytes(const QByteArray& data); + void clear(); + void stop(); + +signals: + void parseError(const QString& error); + +protected: + void run() override; + +private: + PacketQueue* m_packetQueue = nullptr; + + QMutex m_queueMutex; + QWaitCondition m_dataReady; + QQueue m_byteQueue; + + QMutex m_funcMutex; + ParseFunc m_parseFunc; + + QByteArray m_buffer; + std::atomic_bool m_running{false}; +}; + +class SerialDecodeThread : public QThread { + Q_OBJECT +public: + using DecodeFunc = std::function; + + explicit SerialDecodeThread(PacketQueue* packetQueue, FrameQueue* frameQueue, QObject* parent = nullptr); + + void setDecodeFunc(DecodeFunc func); + void stop(); + +signals: + void frameAvailable(); + void decodeError(const QString& error); + +protected: + void run() override; + +private: + PacketQueue* m_packetQueue = nullptr; + FrameQueue* m_frameQueue = nullptr; + + QMutex m_funcMutex; + DecodeFunc m_decodeFunc; + std::atomic_bool m_running{false}; +}; + +class SerialSendWorker : public QObject { + Q_OBJECT +public: + using BuildRequestFunc = std::function; + + explicit SerialSendWorker(QObject* parent = nullptr); + + void setTransport(std::unique_ptr transport); + void setBuildRequestFunc(BuildRequestFunc func); + void setConfig(const SerialConfig& config); + void setRequest(const SensorRequest& request); + + bool openTransport(const SerialConfig& config, QString* error); + void closeTransport(); + void requestOnce(); + +signals: + void bytesReceived(const QByteArray& data); + void requestBuilt(const QByteArray& data); + void writeFailed(const QString& error); + +private: + void updatePolling_(); + void sendRequest_(); + + SerialConfig m_config; + SensorRequest m_request; + BuildRequestFunc m_buildRequest; + + std::unique_ptr m_transport; + QTimer m_pollTimer; + bool m_connected = false; +}; + +#endif // TACTILEIPC3D_SERIAL_THREADS_H diff --git a/src/serial/serial_transport.h b/src/serial/serial_transport.h new file mode 100644 index 0000000..11d4ad6 --- /dev/null +++ b/src/serial/serial_transport.h @@ -0,0 +1,45 @@ +#ifndef TACTILEIPC3D_SERIAL_TRANSPORT_H +#define TACTILEIPC3D_SERIAL_TRANSPORT_H + +#include +#include + +#include "serial_types.h" + +class ISerialTransport : public QObject { + Q_OBJECT +public: + explicit ISerialTransport(QObject* parent = nullptr) : QObject(parent) {} + ~ISerialTransport() override = default; + + virtual bool open(const SerialConfig& config, QString* error) = 0; + virtual void close() = 0; + virtual bool writeBytes(const QByteArray& data, QString* error) = 0; + +signals: + void bytesReceived(const QByteArray& data); +}; + +class NullSerialTransport : public ISerialTransport { + Q_OBJECT +public: + explicit NullSerialTransport(QObject* parent = nullptr) : ISerialTransport(parent) {} + + bool open(const SerialConfig& config, QString* error) override { + Q_UNUSED(config) + if (error) + error->clear(); + return true; + } + + void close() override {} + + bool writeBytes(const QByteArray& data, QString* error) override { + Q_UNUSED(data) + if (error) + error->clear(); + return true; + } +}; + +#endif // TACTILEIPC3D_SERIAL_TRANSPORT_H diff --git a/src/serial/serial_types.h b/src/serial/serial_types.h new file mode 100644 index 0000000..2697f9c --- /dev/null +++ b/src/serial/serial_types.h @@ -0,0 +1,43 @@ +#ifndef TACTILEIPC3D_SERIAL_TYPES_H +#define TACTILEIPC3D_SERIAL_TYPES_H + +#include +#include + + +#define DEBUG_MODE 1 + +enum class DeviceMode { + Master = 0, + Slave = 1, +}; + +struct SerialConfig { + QString portName; + int baudRate = 115200; + int dataBits = 8; + int stopBits = 1; + QString parity = QStringLiteral("N"); + quint8 deviceAddress = 0x01; + DeviceMode mode = DeviceMode::Slave; + int pollIntervalMs = 50; +}; + +struct SensorRequest { + quint8 functionCode = 0x00; + quint32 startAddress = 0; + quint16 dataLength = 0; +}; + +struct SensorSpec { + QString model; + QString version; + int rows = 0; + int cols = 0; + float pitch = 0.0f; + float dotRadius = 0.0f; + float rangeMin = 0.0f; + float rangeMax = 0.0f; +}; + +#endif // TACTILEIPC3D_SERIAL_TYPES_H diff --git a/src/sparkline_plotitem.h b/src/sparkline_plotitem.h new file mode 100644 index 0000000..148946f --- /dev/null +++ b/src/sparkline_plotitem.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "ringbuffer.h" +#include "nice_ticks.h" + +class SparklinePlotItem : public QQuickItem { + Q_OBJECT + + Q_PROPERTY(int viewCount READ viewCount WRITE setViewCount NOTIFY viewCountChanged) + Q_PROPERTY(qulonglong viewStart READ viewStart WRITE setViewStart NOTIFY viewStartChanged) + Q_PROPERTY(bool follow READ follow WRITE setFollow NOTIFY followChanged) + Q_PROPERTY(QColor lineColor READ lineColor WRITE setLineColor NOTIFY lineColorChanged) + Q_PROPERTY(QColor gridColor READ gridColor WRITE setGridColor NOTIFY gridColorChanged) + Q_PROPERTY(QColor textColor READ textColor WRITE setTextColor NOTIFY textColorChanged) + Q_PROPERTY(int yTickCount READ yTickCount WRITE setYTickCount NOTIFY yTickCountChanged) + Q_PROPERTY(int leftPadding READ leftPadding WRITE setLeftPadding NOTIFY leftPaddingChanged) + Q_PROPERTY(int rightPadding READ rightPadding WRITE setRightPadding NOTIFY rightPaddingChanged) + Q_PROPERTY(int topPadding READ topPadding WRITE setTopPadding NOTIFY topPaddingChanged) + Q_PROPERTY(int bottomPadding READ bottomPadding WRITE setBottomPadding NOTIFY bottomPaddingChanged) + +public: + explicit SparklinePlotItem(QQuickItem* parent=nullptr); + + Q_INVOKABLE void append(float y); + Q_INVOKABLE void clear(); + + int viewCount() const { return m_viewCount; } + void setViewCount(int c); + + qulonglong viewStart() const { return m_viewStart; } + void setViewStart(qulonglong s); + + bool follow() const { return m_follow; } + void setFollow(bool f); + + QColor lineColor() const { return m_lineColor; } + void setLineColor(const QColor& c); + + QColor gridColor() const { return m_gridColor; } + void setGridColor(const QColor& c); + + QColor textColor() const { return m_textColor; } + void setTextColor(const QColor& c); + + int yTickCount() const { return m_yTickCount; } + void setYTickCount(int c); + + int leftPadding() const { return m_leftPad; } + void setLeftPadding(int v); + + int rightPadding() const { return m_rightPad; } + void setRightPadding(int v); + + int topPadding() const { return m_topPad; } + void setTopPadding(int v); + + int bottomPadding() const { return m_topPad; } + void setBottomPadding(int v); + +signals: + void viewCountChanged(); + void viewStartChanged(); + void followChanged(); + void lineColorChanged(); + void gridColorChanged(); + void textColorChanged(); + void yTickCountChanged(); + void leftPaddingChanged(); + void rightPaddingChanged(); + void topPaddingChanged(); + void bottomPaddingChanged(); + +protected: + QSGNode* updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) override; + +private: + QSGTexture* getTextTexture(const QString& text, const QFont& font, QSize* outSize = nullptr); +private: + std::unique_ptr> m_buf; + + int m_viewCount = 300; + qulonglong m_viewStart = 0; + bool m_follow = true; + + QColor m_lineColor = QColor("#39D535"); + QColor m_gridColor = QColor(255, 255, 255, 35); + QColor m_textColor = QColor(255, 255, 255, 200); + + int m_yTickCount = 5; + int m_leftPad = 40; + int m_rightPad = 0; + int m_topPad = 10; + int m_bottomPad = 18; + + struct TexEntry { + QSGTexture* tex = nullptr; + QSize size; + }; + std::unordered_map m_textCache; +}; diff --git a/src/sparkling_plotitem.cpp b/src/sparkling_plotitem.cpp new file mode 100644 index 0000000..fb4f13a --- /dev/null +++ b/src/sparkling_plotitem.cpp @@ -0,0 +1,392 @@ +#include "ringbuffer.h" +#include "sparkline_plotitem.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +SparklinePlotItem::SparklinePlotItem(QQuickItem* parent) + : QQuickItem(parent) { + + setFlag(ItemHasContents, true); + m_buf = std::make_unique>(10'000'000); +} + +void SparklinePlotItem::append(float y) { + m_buf->push(y); + + if (m_follow) { + const auto newest = m_buf->newestGlobalIndex(); + + if (newest + 1 >= (uint64_t)m_viewCount) { + m_viewStart = newest + 1 - (uint64_t)m_viewCount; + } + else { + m_viewStart = 0; + } + emit viewStartChanged(); + } +} + +void SparklinePlotItem::clear() { + m_textCache.clear(); + m_buf = std::make_unique>(m_buf->capacity()); + m_viewStart = 0; + emit viewStartChanged(); + update(); +} + +void SparklinePlotItem::setViewCount(int c) { + c = std::max(10, c); + if (m_viewCount == c) + return; + m_viewCount = c; + emit viewCountChanged(); + update(); +} + +void SparklinePlotItem::setViewStart(qulonglong s) { + if (m_viewStart == s) + return; + m_viewStart = s; + emit viewStartChanged(); + update(); +} + +void SparklinePlotItem::setFollow(bool f) { + if (m_follow == f) + return; + m_follow = f; + emit followChanged(); +} + +void SparklinePlotItem::setLineColor(const QColor& c) { + if (m_lineColor == c) + return; + m_lineColor = c; + emit lineColorChanged(); + update(); +} + +void SparklinePlotItem::setGridColor(const QColor& c) { + if (m_gridColor == c) { + return; + } + m_gridColor = c; + emit gridColorChanged(); + update(); +} + +void SparklinePlotItem::setTextColor(const QColor& c) { + if (m_textColor == c) { + return; + } + m_textColor = c; + emit textColorChanged(); + update(); +} + +void SparklinePlotItem::setYTickCount(int c) { + c = std::max(3, std::min(8, c)); + if (m_yTickCount == c) + return; + m_yTickCount = c; + emit yTickCountChanged(); + update(); +} + +void SparklinePlotItem::setLeftPadding(int v) { + if(m_leftPad==v) + return; m_leftPad=v; + emit leftPaddingChanged(); + update(); +} +void SparklinePlotItem::setRightPadding(int v){ if(m_rightPad==v) return; m_rightPad=v; emit rightPaddingChanged(); update(); } +void SparklinePlotItem::setTopPadding(int v){ if(m_topPad==v) return; m_topPad=v; emit topPaddingChanged(); update(); } +void SparklinePlotItem::setBottomPadding(int v){ if(m_bottomPad==v) return; m_bottomPad=v; emit bottomPaddingChanged(); update(); } + +QSGTexture* SparklinePlotItem::getTextTexture(const QString& text, const QFont& font, QSize* outSize) { + const qreal dpr = window() ? window()->devicePixelRatio() : 1.0; + const QString key = text + "|" + font.family() + "|" + QString::number(font.pixelSize()) + + "|" + QString::number(dpr, 'f', 2); + auto it = m_textCache.find(key); + if (it != m_textCache.end()) { + if (outSize) + *outSize = it->second.size; + return it->second.tex; + } + + QFont f = font; + if (f.pixelSize() < 0) { + f.setPixelSize(10); + } + + QFontMetrics fm(f); + QSize sz = fm.size(Qt::TextSingleLine, text) + QSize(6, 4); + + const QSize pixelSize(qMax(1, qRound(sz.width() * dpr)), qMax(1, qRound(sz.height() * dpr))); + QImage img(pixelSize, QImage::Format_ARGB32_Premultiplied); + img.setDevicePixelRatio(dpr); + img.fill(Qt::transparent); + + QPainter p(&img); + p.setRenderHint(QPainter::Antialiasing, true); + p.setFont(f); + p.setPen(m_textColor); + p.drawText(QRect(QPoint(0, 0), sz), Qt::AlignCenter, text); + p.end(); + + QSGTexture* tex = window()->createTextureFromImage(img); + + m_textCache[key] = { tex, sz }; + if (outSize) + *outSize = sz; + return tex; +} + +static void downsampleMinMax(const RingBuffer& buf, uint64_t startGidx, uint64_t count, int widthPx, + std::vector& out, double& outMinY, double& outMaxY) { + out.clear(); + outMinY = 1e30; + outMaxY = -1e30; + if (count < 2 || widthPx <= 2) + return; + + const int buckets = std::max(2, widthPx); + const double samplesPerBucket = double(count) / double(buckets); + + out.reserve(buckets * 2); + + for (int b = 0; b < buckets; ++b) { + uint64_t s0 = startGidx + uint64_t(std::floor(b * samplesPerBucket)); + uint64_t s1 = startGidx + uint64_t(std::floor((b + 1) * samplesPerBucket)); + if (s1 <= s0) { + s1 = s0 + 1; + } + if (s1 > startGidx + count) { + s1 = startGidx + count; + } + + double v; + bool ok = false; + double mn = 1e30; + double mx = -1e30; + + for (uint64_t g = s0; g < s1; ++g) { + if (buf.readByGlobalIndex(g, v)) { + ok = true; + mn = std::min(mn, (double)v); + mx = std::max(mx, (double)v); + } + } + + if (!ok) { + continue; + } + + outMinY = std::min(outMinY, mn); + outMaxY = std::max(outMaxY, mx); + + out.emplace_back(double(b), mn); + out.emplace_back(double(b), mx); + } +} + +QSGNode* SparklinePlotItem::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) { + if (!window()) { + return oldNode; + } + + const qreal dpr = window()->devicePixelRatio(); + auto alignToPixel = [dpr](float v) -> float { + return (dpr > 0.0) ? (std::round(v * dpr) / dpr) : v; + }; + + QSGNode* root = oldNode; + if (!root) { + root = new QSGNode(); + } + + QSGGeometryNode* gridNode = nullptr; + QSGGeometryNode* lineNode = nullptr; + + if (root->childCount() >= 2) { + gridNode = static_cast(root->childAtIndex(0)); + lineNode = static_cast(root->childAtIndex(1)); + } + else { + gridNode = new QSGGeometryNode(); + auto* gridGeom = new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(), 0); + gridGeom->setDrawingMode(QSGGeometry::DrawLines); + gridNode->setGeometry(gridGeom); + gridNode->setFlag(QSGNode::OwnsGeometry); + + auto* gridMat = new QSGFlatColorMaterial(); + gridMat->setColor(m_gridColor); + gridNode->setMaterial(gridMat); + gridNode->setFlag(QSGNode::OwnsMaterial); + + // Line node + lineNode = new QSGGeometryNode(); + auto* lineGeom = new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(), 0); + lineGeom->setDrawingMode(QSGGeometry::DrawLineStrip); + lineNode->setGeometry(lineGeom); + lineNode->setFlag(QSGNode::OwnsGeometry); + + auto* lineMat = new QSGFlatColorMaterial(); + lineMat->setColor(m_lineColor); + lineNode->setMaterial(lineMat); + lineNode->setFlag(QSGNode::OwnsMaterial); + + root->appendChildNode(gridNode); + root->appendChildNode(lineNode); + } + + static_cast(gridNode->material())->setColor(m_gridColor); + static_cast(lineNode->material())->setColor(m_lineColor); + + // 计算绘图区 + const float W = float(width()); + const float H = float(height()); + const float left = float(m_leftPad); + const float right = W - float(m_rightPad); + const float top = float(m_topPad); + const float bottom = H - float(m_bottomPad); + + const float plotW = std::max(1.0f, right - left); + const float plotH = std::max(1.0f, bottom - top); + + const uint64_t oldest = m_buf->oldestGlobalIndex(); + const uint64_t newest = m_buf->newestGlobalIndex(); + const uint64_t sz = m_buf->size(); + if (sz < 2) { + gridNode->geometry()->allocate(0); + lineNode->geometry()->allocate(0); + return root; + } + + // clamp viewStart/viewCount + uint64_t startG = std::max(oldest, (uint64_t)m_viewStart); + uint64_t count = (uint64_t)m_viewCount; + if (startG + count > newest + 1) { + if (newest + 1 >= count) startG = newest + 1 - count; + else startG = oldest; + } + startG = std::max(oldest, startG); + + // 降采样 + std::vector ds; + ds.reserve(int(plotW) * 2); + double rawMinY, rawMaxY; + downsampleMinMax(*m_buf, startG, count, int(plotW), ds, rawMinY, rawMaxY); + if (ds.size() < 2) { + gridNode->geometry()->allocate(0); + lineNode->geometry()->allocate(0); + return root; + } + + // 自动 nice ticks + // 增加一点 padding,避免贴边 + const double pad = (rawMaxY - rawMinY) * 0.08 + 1e-9; + auto ticks = niceTicks(rawMinY - pad, rawMaxY + pad, m_yTickCount); + + const double yMin = ticks.niceMin; + const double yMax = ticks.niceMax; + const double yRange = (yMax - yMin != 0) ? (yMax - yMin) : 1.0; + + // --- 1) 网格线(水平线) --- + { + auto* geom = gridNode->geometry(); + // 每个 tick 画一条水平线 = 2 points + const int n = int(ticks.ticks.size()); + geom->allocate(n * 2); + auto* v = geom->vertexDataAsPoint2D(); + + for (int i = 0; i < n; ++i) { + double tv = ticks.ticks[i]; + float yn = float((tv - yMin) / yRange); + float py = alignToPixel(bottom - yn * plotH); + + v[i*2 + 0].set(left, py); + v[i*2 + 1].set(right, py); + } + gridNode->markDirty(QSGNode::DirtyGeometry); + } + + // --- 2) 折线(注意 min/max per pixel 输出是 [x, min][x,max],可直接 line strip) --- + { + auto* geom = lineNode->geometry(); + const int vCount = int(ds.size()); + geom->allocate(vCount); + auto* v = geom->vertexDataAsPoint2D(); + + const double xMaxBucket = ds.back().x(); + const double xDen = (xMaxBucket > 0) ? xMaxBucket : 1.0; + + for (int i = 0; i < vCount; ++i) { + const double bx = ds[i].x(); + const double yv = ds[i].y(); + float xn = float(bx / xDen); + float yn = float((yv - yMin) / yRange); + + float px = left + xn * plotW; + float py = bottom - yn * plotH; + v[i].set(px, py); + } + lineNode->markDirty(QSGNode::DirtyGeometry); + } + + // --- 3) Tick 标签 --- + // 先删除旧 label nodes(保留 root 的前两个 children) + while (root->childCount() > 2) { + delete root->childAtIndex(2); + } + + QFont font; + font.setPixelSize(10); + + const int nTicks = int(ticks.ticks.size()); + for (int i = 0; i < nTicks; ++i) { + const double tv = ticks.ticks[i]; + QString label = QString::number(tv, 'g', 4); + + QSize logicalSize; + QSGTexture* tex = getTextTexture(label, font, &logicalSize); + if (!tex) continue; + + float yn = float((tv - yMin) / yRange); + float py = bottom - yn * plotH; + + // 创建 texture node + auto* tnode = new QSGSimpleTextureNode(); + tnode->setTexture(tex); + // label 放到左侧 padding 区 + float tx = alignToPixel(2.0f); + float ty = alignToPixel(py - logicalSize.height() / 2.0f); + tnode->setRect(QRectF(tx, ty, logicalSize.width(), logicalSize.height())); + root->appendChildNode(tnode); + } + + return root; +} diff --git a/src/translation_manager.cpp b/src/translation_manager.cpp new file mode 100644 index 0000000..ba7bd8d --- /dev/null +++ b/src/translation_manager.cpp @@ -0,0 +1,45 @@ +#include "translation_manager.h" + +#include +#include + +TranslationManager::TranslationManager(QObject* parent) + : QObject(parent) { +} + +bool TranslationManager::setLanguage(const QString& language) { + if (language == m_language) { + return true; + } + + if (language.isEmpty()) { + if (m_translator) { + QCoreApplication::removeTranslator(m_translator.get()); + m_translator.reset(); + } + m_language.clear(); + ++m_retranslateToken; + emit retranslateTokenChanged(); + emit languageChanged(); + return true; + } + + auto translator = std::make_unique(); + const QString qmPath = QStringLiteral(":/i18n/app_%1.qm").arg(language); + if (!translator->load(qmPath)) { + qWarning() << "Failed to load translation:" << language; + return false; + } + + if (m_translator) { + QCoreApplication::removeTranslator(m_translator.get()); + } + m_translator = std::move(translator); + QCoreApplication::installTranslator(m_translator.get()); + + m_language = language; + ++m_retranslateToken; + emit retranslateTokenChanged(); + emit languageChanged(); + return true; +} diff --git a/src/translation_manager.h b/src/translation_manager.h new file mode 100644 index 0000000..12e35b4 --- /dev/null +++ b/src/translation_manager.h @@ -0,0 +1,31 @@ +#ifndef TRANSLATION_MANAGER_H +#define TRANSLATION_MANAGER_H + +#include +#include +#include +#include + +class TranslationManager : public QObject { + Q_OBJECT + Q_PROPERTY(int retranslateToken READ retranslateToken NOTIFY retranslateTokenChanged) + Q_PROPERTY(QString language READ language NOTIFY languageChanged) + +public: + explicit TranslationManager(QObject* parent = nullptr); + + Q_INVOKABLE bool setLanguage(const QString& language); + int retranslateToken() const { return m_retranslateToken; } + QString language() const { return m_language; } + +signals: + void retranslateTokenChanged(); + void languageChanged(); + +private: + std::unique_ptr m_translator; + QString m_language; + int m_retranslateToken = 0; +}; + +#endif diff --git a/test/onlygl/CMakeLists.txt b/test/onlygl/CMakeLists.txt index ac99b8a..8c925be 100644 --- a/test/onlygl/CMakeLists.txt +++ b/test/onlygl/CMakeLists.txt @@ -1,6 +1,8 @@ cmake_minimum_required(VERSION 3.5) project(base-project) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(X11 REQUIRED) add_executable( ${PROJECT_NAME} diff --git a/test/onlygl/README.md b/test/onlygl/README.md new file mode 100644 index 0000000..1cd1333 --- /dev/null +++ b/test/onlygl/README.md @@ -0,0 +1,28 @@ +# GLFW port notes + +## 这次重要修改/新增了什么,为什么这么改 +- main.cpp: 改成纯 GLFW/GLAD 版本,重建相机/输入、面板/点阵 VAO/VBO、实例化上传逻辑,避免 Qt 依赖,方便直接跑 OpenGL 核心模式。 +- shaders/panel.frag & shaders/dots.frag: 修过语法/Uniform,对接 GLFW 管线,并保持金属质感(不再做数据伪彩色);点用内置小纹理,省掉外部贴图。 +- myshader.hh: 增加常用 uniform setter、修正 program 链接日志、析构释放 program,方便传矩阵/向量。 +- CMakeLists.txt: 指定 C++17,保证可用 std::clamp 等工具。 +- README.md: 补了构建、运行和操作说明,标明从项目根运行以找到 `./shaders`。 + +## Controls +- 右键拖拽:绕物体旋转相机(yaw/pitch) +- 滚轮:调节视角 FOV 缩放 +- Esc:退出 + +## Build & run +```bash +mkdir -p build +cd build +cmake .. +make -j +``` + +从项目根目录运行(确保能找到 `./shaders`): +```bash +./build/base-project +``` + +目前用 `update_demo_values` 生成简单波纹示例数据,如需接入传感器数据,替换 main.cpp 里的该函数并在循环前设置好 `set_spec` / `set_panel_size`。 diff --git a/test/onlygl/images/metal.jpeg b/test/onlygl/images/metal.jpeg new file mode 100644 index 0000000..ae2b57f Binary files /dev/null and b/test/onlygl/images/metal.jpeg differ diff --git a/test/onlygl/main.cpp b/test/onlygl/main.cpp index 070e3af..dcdbb5c 100644 --- a/test/onlygl/main.cpp +++ b/test/onlygl/main.cpp @@ -1,43 +1,122 @@ -#include +// GLFW 版本的渲染入口:画触觉面板盒子 + 点阵实例化圆点, +// 右键拖拽环绕相机,滚轮调 FOV,内置简单波纹数据做演示。 +#include +#include +#include +#include + #include #include -#include -#include -#include "camera.h" +#include +#include #include "myshader.hh" +#include "stb_image_wrap.h" -float deltaTime = 0.0f; -float lastFrame = 0.0f; -float yaw = -90.0f; -float pitch = 0.0f; -float fov = 45.0f; -Camera camera(glm::vec3(0.0f, 0.0f, 3.0)); +struct AppState { + // 点阵规格 + int rows = 8; + int cols = 10; + float pitch = 0.025f; + float dotRadius = 0.008f; + // 面板尺寸 + float panelW = 0.25f; + float panelH = 0.35f; + float panelD = 0.01f; + // 数据范围 + int minV = 0; + int maxV = 100; + // 渲染模式:0=有光照,1=无光照(flat/unlit) + int renderMode = 1; -int dot_rows = 3; -int dot_cols = 4; -float panel_width = 1.2f; -float panel_height = 0.08f; -float panel_deep = 0.08f; + // 相机状态(环绕) + float camYaw = -90.0f; + float camPitch = 0.0f; + float zoom = 45.0f; + bool rightDown = false; + double lastMouseX = 0.0; + double lastMouseY = 0.0; -Shader bg_shader(); -Shader panel_shader(); -Shader dots_shader(); + bool valuesDirty = true; + std::vector values; + + glm::mat4 mvp{1.0f}; + glm::vec3 cameraPos{0.0f, 0.0f, 3.0f}; +}; + +AppState g_state; + +Shader* bg_shader = nullptr; +Shader* room_shader = nullptr; +Shader* panel_shader = nullptr; +Shader* dots_shader = nullptr; unsigned int panel_vao = 0; unsigned int panel_vbo = 0; unsigned int panel_ibo = 0; +int panel_index_count = 0; + +unsigned int room_vao = 0; +unsigned int room_vbo = 0; +unsigned int room_ibo = 0; +int room_index_count = 0; unsigned int dots_vao = 0; unsigned int dots_vbo = 0; unsigned int instance_vbo = 0; +int instance_count = 0; + +unsigned int dot_tex = 0; unsigned int bg_vao = 0; unsigned int bg_vbo = 0; -void framebuffer_size_callback(GLFWwindow* window, int width, int height) { +unsigned int metal_tex = 0; +bool panel_geometry_dirty = true; +bool dot_geometry_dirty = true; + +void framebuffer_size_callback(GLFWwindow* /*window*/, int width, int height) { + // 视口随窗口大小变化 glViewport(0, 0, width, height); } +void mouse_button_callback(GLFWwindow* window, int button, int action, int /*mods*/) { + // 右键按下/抬起,用于开启/关闭环绕相机拖拽 + if (button == GLFW_MOUSE_BUTTON_RIGHT) { + if (action == GLFW_PRESS) { + g_state.rightDown = true; + glfwGetCursorPos(window, &g_state.lastMouseX, &g_state.lastMouseY); + } else if (action == GLFW_RELEASE) { + g_state.rightDown = false; + } + } +} + +void cursor_pos_callback(GLFWwindow* /*window*/, double xpos, double ypos) { + // 右键拖拽时,根据鼠标增量更新 yaw/pitch + if (!g_state.rightDown) + return; + + const float dx = static_cast(xpos - g_state.lastMouseX); + const float dy = static_cast(ypos - g_state.lastMouseY); + g_state.lastMouseX = xpos; + g_state.lastMouseY = ypos; + + g_state.camYaw += dx * 0.3f; + if (g_state.camYaw >= -70) { + g_state.camYaw = -70; + } + if (g_state.camYaw <= -110) { + g_state.camYaw = -110; + } + g_state.camPitch = std::clamp(g_state.camPitch + dy * 0.3f, -20.0f, 20.0f); +} + +void scroll_callback(GLFWwindow* /*window*/, double /*xoffset*/, double yoffset) { + // 滚轮调整视角 FOV(越小越近) + const float factor = std::pow(0.9f, static_cast(yoffset)); + g_state.zoom = std::clamp(g_state.zoom * factor, 5.0f, 80.0f); +} + void process_input(GLFWwindow* window) { if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) { glfwSetWindowShouldClose(window, true); @@ -45,58 +124,198 @@ void process_input(GLFWwindow* window) { } GLFWwindow* glfw_init() { - glfwInit(); + // 初始化 GLFW + GLAD,创建窗口与上下文 + if (!glfwInit()) { + std::cerr << "Failed to init GLFW" << std::endl; + return nullptr; + } glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); #if __APPLE__ glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); #endif - GLFWwindow* window = glfwCreateWindow(800, 600, "Tactile Module Test", NULL, NULL); - if (window == NULL) { - std::cout << "Failed to create GLFW window" << std::endl; + GLFWwindow* window = glfwCreateWindow(800, 600, "Tactile Module Test (GLFW)", nullptr, nullptr); + if (window == nullptr) { + std::cerr << "Failed to create GLFW window" << std::endl; glfwTerminate(); - return NULL; + return nullptr; } glfwMakeContextCurrent(window); if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) { - std::cout << "Failed to initialize GLAD" << std::endl; - return NULL; + std::cerr << "Failed to initialize GLAD" << std::endl; + glfwDestroyWindow(window); + glfwTerminate(); + return nullptr; } - glViewport(0, 0, 800, 600); glfwSetFramebufferSizeCallback(window, framebuffer_size_callback); + glfwSetMouseButtonCallback(window, mouse_button_callback); + glfwSetCursorPosCallback(window, cursor_pos_callback); + glfwSetScrollCallback(window, scroll_callback); + glViewport(0, 0, 800, 600); + glfwSwapInterval(1); return window; } -void glfw_window_loop(GLFWwindow* window) { - while (!glfwWindowShouldClose(window)) { - process_input(window); - glfwSwapBuffers(window); - glfwPollEvents(); - } +int dot_count() { + // 当前点阵总数 + return g_state.rows * g_state.cols; } -void destroy_context() { - if (panel_vao) { - glDeleteVertexArrays(1, &panel_vao); +void set_panel_size(float w, float h, float d) { + g_state.panelW = w; + g_state.panelH = h; + g_state.panelD = d; + panel_geometry_dirty = true; +} + +void set_spec(int rows, int cols, float pitch, float dotRadius) { + // 设置点阵规格,同时自动计算面板宽/深以留出圆点边界 + g_state.rows = std::max(0, rows); + g_state.cols = std::max(0, cols); + g_state.pitch = std::max(0.0f, pitch); + g_state.dotRadius = std::max(0.0f, dotRadius); + + const float gridW = static_cast(std::max(0, g_state.cols - 1)) * g_state.pitch; + const float gridD = static_cast(std::max(0, g_state.rows - 1)) * g_state.pitch; + g_state.panelW = gridW + 2.0f * g_state.dotRadius; + g_state.panelH = gridD + 2.0f * g_state.dotRadius; + panel_geometry_dirty = true; + dot_geometry_dirty = true; + + g_state.values.assign(dot_count(), static_cast(g_state.minV)); + g_state.valuesDirty = true; +} + +void init_background_geometry() { + // 背景网格:全屏二三角形,顶点坐标直接在 NDC 空间 + if (bg_vbo) { + glDeleteBuffers(1, &bg_vbo); + bg_vbo = 0; } - if (panel_vbo) { - glDeleteBuffers(1, &panel_vbo); + if (bg_vao) { + glDeleteVertexArrays(1, &bg_vao); + bg_vao = 0; } - if (panel_ibo) { - glDeleteBuffers(1, &panel_ibo); + + const float verts[] = { + -1.0f, -1.0f, + 1.0f, -1.0f, + 1.0f, 1.0f, + -1.0f, -1.0f, + 1.0f, 1.0f, + -1.0f, 1.0f, + }; + + glGenVertexArrays(1, &bg_vao); + glBindVertexArray(bg_vao); + + glGenBuffers(1, &bg_vbo); + glBindBuffer(GL_ARRAY_BUFFER, bg_vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW); + + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0); + + glBindVertexArray(0); +} + +void init_room_geometry() { + // 房间:一个“倒扣的大盒子”(立方体),我们从里面看,所以法线朝向盒子内部 + if (room_ibo) { + glDeleteBuffers(1, &room_ibo); + room_ibo = 0; } - if (dots_vao) { - glDeleteVertexArrays(1, &dots_vao); + if (room_vbo) { + glDeleteBuffers(1, &room_vbo); + room_vbo = 0; } - if (dots_vbo) { - glDeleteBuffers(1, &dots_vbo); + if (room_vao) { + glDeleteVertexArrays(1, &room_vao); + room_vao = 0; } + + struct V { + float x, y, z; + float nx, ny, nz; + }; + + // 单位立方体:顶点范围 [-1, 1],真正房间大小在 room.vert 里通过 uRoomHalfSize 缩放 + // 下面的法线是“朝内”的(方便在房间内部打光) + V verts[24] = { + // floor (y = -1), normal +Y + {-1, -1, -1, 0, +1, 0}, + {+1, -1, -1, 0, +1, 0}, + {+1, -1, +1, 0, +1, 0}, + {-1, -1, +1, 0, +1, 0}, + + // ceiling (y = +1), normal -Y + {-1, +1, +1, 0, -1, 0}, + {+1, +1, +1, 0, -1, 0}, + {+1, +1, -1, 0, -1, 0}, + {-1, +1, -1, 0, -1, 0}, + + // back wall (z = -1), normal +Z + {-1, +1, -1, 0, 0, +1}, + {+1, +1, -1, 0, 0, +1}, + {+1, -1, -1, 0, 0, +1}, + {-1, -1, -1, 0, 0, +1}, + + // front wall (z = +1), normal -Z + {+1, +1, +1, 0, 0, -1}, + {-1, +1, +1, 0, 0, -1}, + {-1, -1, +1, 0, 0, -1}, + {+1, -1, +1, 0, 0, -1}, + + // left wall (x = -1), normal +X + {-1, +1, +1, +1, 0, 0}, + {-1, +1, -1, +1, 0, 0}, + {-1, -1, -1, +1, 0, 0}, + {-1, -1, +1, +1, 0, 0}, + + // right wall (x = +1), normal -X + {+1, +1, -1, -1, 0, 0}, + {+1, +1, +1, -1, 0, 0}, + {+1, -1, +1, -1, 0, 0}, + {+1, -1, -1, -1, 0, 0}, + }; + + unsigned int idx[36] = { + 0, 1, 2, 0, 2, 3, // floor + 4, 5, 6, 4, 6, 7, // ceiling + 8, 9, 10, 8, 10, 11, // back + 12, 13, 14, 12, 14, 15, // front + 16, 17, 18, 16, 18, 19, // left + 20, 21, 22, 20, 22, 23 // right + }; + room_index_count = 36; + + glGenVertexArrays(1, &room_vao); + glBindVertexArray(room_vao); + + glGenBuffers(1, &room_vbo); + glBindBuffer(GL_ARRAY_BUFFER, room_vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW); + + glGenBuffers(1, &room_ibo); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, room_ibo); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(idx), idx, GL_STATIC_DRAW); + + // layout 和 panel 一样: + // location 0: position + // location 1: normal + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(V), (void*)0); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(V), (void*)(3 * sizeof(float))); + + glBindVertexArray(0); } void init_panel_geometry() { + // 面板盒子:VAO+VBO+IBO,包含顶面/底面/侧面 6 个面 if (panel_ibo) { glDeleteBuffers(1, &panel_ibo); panel_ibo = 0; @@ -110,65 +329,59 @@ void init_panel_geometry() { panel_vao = 0; } - const float y = panel_height * 0.5f; - const float hw = panel_width * 0.5f; - const float hd = panel_deep * 0.5; - using V = struct { + const float y = g_state.panelH * 0.5f; + const float hw = g_state.panelW * 0.5f; + const float hd = g_state.panelD * 0.5f; + + struct V { float x, y, z; float nx, ny, nz; }; + V verts[24] = { - // +Y 顶面 (normal 0, +1, 0) - {-hw, +y, -hd, 0, +1, 0}, // 0 - {+hw, +y, -hd, 0, +1, 0}, // 1 - {+hw, +y, +hd, 0, +1, 0}, // 2 - {-hw, +y, +hd, 0, +1, 0}, // 3 - - // +Z 前面 (normal 0, 0, +1) - {-hw, +y, +hd, 0, 0, +1}, // 4 - {+hw, +y, +hd, 0, 0, +1}, // 5 - {+hw, -y, +hd, 0, 0, +1}, // 6 - {-hw, -y, +hd, 0, 0, +1}, // 7 - - // -Y 底面 (normal 0, -1, 0) - {-hw, -y, +hd, 0, -1, 0}, // 8 - {+hw, -y, +hd, 0, -1, 0}, // 9 - {+hw, -y, -hd, 0, -1, 0}, // 10 - {-hw, -y, -hd, 0, -1, 0}, // 11 - - // -Z 后面 (normal 0, 0, -1) - {+hw, +y, -hd, 0, 0, -1}, // 12 - {-hw, +y, -hd, 0, 0, -1}, // 13 - {-hw, -y, -hd, 0, 0, -1}, // 14 - {+hw, -y, -hd, 0, 0, -1}, // 15 - - // -X 左面 (normal -1, 0, 0) - {-hw, +y, -hd, -1, 0, 0}, // 16 - {-hw, +y, +hd, -1, 0, 0}, // 17 - {-hw, -y, +hd, -1, 0, 0}, // 18 - {-hw, -y, -hd, -1, 0, 0}, // 19 - - // +X 右面 (normal +1, 0, 0) - {+hw, +y, +hd, +1, 0, 0}, // 20 - {+hw, +y, -hd, +1, 0, 0}, // 21 - {+hw, -y, -hd, +1, 0, 0}, // 22 - {+hw, -y, +hd, +1, 0, 0}, // 23 + {-hw, +y, -hd, 0, +1, 0}, + {+hw, +y, -hd, 0, +1, 0}, + {+hw, +y, +hd, 0, +1, 0}, + {-hw, +y, +hd, 0, +1, 0}, + {-hw, +y, +hd, 0, 0, +1}, + {+hw, +y, +hd, 0, 0, +1}, + {+hw, -y, +hd, 0, 0, +1}, + {-hw, -y, +hd, 0, 0, +1}, + {-hw, -y, +hd, 0, -1, 0}, + {+hw, -y, +hd, 0, -1, 0}, + {+hw, -y, -hd, 0, -1, 0}, + {-hw, -y, -hd, 0, -1, 0}, + {+hw, +y, -hd, 0, 0, -1}, + {-hw, +y, -hd, 0, 0, -1}, + {-hw, -y, -hd, 0, 0, -1}, + {+hw, -y, -hd, 0, 0, -1}, + {-hw, +y, -hd, -1, 0, 0}, + {-hw, +y, +hd, -1, 0, 0}, + {-hw, -y, +hd, -1, 0, 0}, + {-hw, -y, -hd, -1, 0, 0}, + {+hw, +y, +hd, +1, 0, 0}, + {+hw, +y, -hd, +1, 0, 0}, + {+hw, -y, -hd, +1, 0, 0}, + {+hw, -y, +hd, +1, 0, 0}, }; unsigned int idx[36] = { - 0, 1, 2, 0, 2, 3, // top - 4, 5, 6, 4, 6, 7, // front - 8, 9, 10, 8, 10,11, // bottom - 12,13,14, 12,14,15, // back - 16,17,18, 16,18,19, // left - 20,21,22, 20,22,23 // right + 0, 1, 2, 0, 2, 3, + 4, 5, 6, 4, 6, 7, + 8, 9, 10, 8, 10, 11, + 12, 13, 14, 12, 14, 15, + 16, 17, 18, 16, 18, 19, + 20, 21, 22, 20, 22, 23 }; - int panel_index_count = 36; + panel_index_count = 36; + glGenVertexArrays(1, &panel_vao); glBindVertexArray(panel_vao); + glGenBuffers(1, &panel_vbo); glBindBuffer(GL_ARRAY_BUFFER, panel_vbo); glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW); + glGenBuffers(1, &panel_ibo); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, panel_ibo); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(idx), idx, GL_STATIC_DRAW); @@ -176,28 +389,386 @@ void init_panel_geometry() { glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(V), (void*)0); glEnableVertexAttribArray(1); - glVertexAttribPointer(1, 3, GL_FLOAT, GL_FLOAT, sizeof(V), (void*)(3 * sizeof(float))); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(V), (void*)(3 * sizeof(float))); glBindVertexArray(0); } -void set_panel_size(float w, float h, float d) { - panel_width = w; - panel_height = h; - panel_deep = d; +void init_dot_geometry() { + // 圆点:基于一个单位 quad,使用 instanced attributes 传位置/值 + if (instance_vbo) { + glDeleteBuffers(1, &instance_vbo); + instance_vbo = 0; + } + if (dots_vbo) { + glDeleteBuffers(1, &dots_vbo); + dots_vbo = 0; + } + if (dots_vao) { + glDeleteVertexArrays(1, &dots_vao); + dots_vao = 0; + } + + struct V { + float x, y; + float u, v; + }; + + V quad[6] = { + {-1, -1, 0, 0}, + { 1, -1, 1, 0}, + { 1, 1, 1, 1}, + {-1, -1, 0, 0}, + { 1, 1, 1, 1}, + {-1, 1, 0, 1}, + }; + + glGenVertexArrays(1, &dots_vao); + glBindVertexArray(dots_vao); + + glGenBuffers(1, &dots_vbo); + glBindBuffer(GL_ARRAY_BUFFER, dots_vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(V), (void*)0); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(V), (void*)(2 * sizeof(float))); + + glGenBuffers(1, &instance_vbo); + glBindBuffer(GL_ARRAY_BUFFER, instance_vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(float) * 3 * std::max(1, dot_count()), nullptr, GL_DYNAMIC_DRAW); + + glEnableVertexAttribArray(2); + glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); + glVertexAttribDivisor(2, 1); + + glEnableVertexAttribArray(3); + glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)(2 * sizeof(float))); + glVertexAttribDivisor(3, 1); + + glBindVertexArray(0); +} + +// void init_dot_texture() { +// // 简单 4x4 RGBA 程序化纹理,模拟金属纹理感 +// if (dot_tex) { +// glDeleteTextures(1, &dot_tex); +// dot_tex = 0; +// } + +// // Simple procedural 4x4 texture to mimic a brushed metal feel. +// const unsigned char pixels[] = { +// 180, 175, 170, 255, 185, 180, 175, 255, 190, 185, 180, 255, 185, 180, 175, 255, +// 185, 180, 175, 255, 190, 185, 180, 255, 195, 190, 185, 255, 190, 185, 180, 255, +// 190, 185, 180, 255, 195, 190, 185, 255, 200, 195, 190, 255, 195, 190, 185, 255, +// 185, 180, 175, 255, 190, 185, 180, 255, 195, 190, 185, 255, 190, 185, 180, 255, +// }; + +// glGenTextures(1, &dot_tex); +// glBindTexture(GL_TEXTURE_2D, dot_tex); +// glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); +// glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); +// glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,GL_LINEAR_MIPMAP_LINEAR); +// glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,GL_LINEAR_MIPMAP_LINEGL_LINEARAR); +// glPixelStorei(GL_UNPACK_ALIGNMENT, 1GL_LINEARGL_CLAMP_TO_EDGE glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 40, GL_RGBA, GL_UNSIGNED_BYTE, pixels); +// glGenerateMipmap(GL_TEXTURE_GL_CLAMP_TO_EDGE2D +// glPixelStorei(GL_UNPACK_ALIGNMENT, 1); +// glBindTexture(GL_TEXTURE_2D, 0); +// } + +void init_programs() { + // 加载并编译 shader program + const std::string vsd_path = "../shaders/dots.vert"; + const std::string fsd_path = "../shaders/dots.frag"; + glBindTexture(GL_TEXTURE_2D, 0); + const std::string vsb_path = "../shaders/bg.vert"; + const std::string fsb_path = "../shaders/bg.frag"; + const std::string vsr_path = "../shaders/room.vert"; + const std::string fsr_path = "../shaders/room.frag"; + const std::string vsp_path = "../shaders/panel.vert"; + const std::string fsp_path = "../shaders/panel.frag"; + + bg_shader = new Shader(vsb_path.c_str(), fsb_path.c_str()); + room_shader = new Shader(vsr_path.c_str(), fsr_path.c_str()); + dots_shader = new Shader(vsd_path.c_str(), fsd_path.c_str()); + panel_shader = new Shader(vsp_path.c_str(), fsp_path.c_str()); +} + +void update_instance_buffer_if_needed() { + // 如果有新数据,重新填充实例缓冲:每个点 3 个 float (x,z,value) + if (dot_count() <= 0) { + instance_count = 0; + return; + } + if (!g_state.valuesDirty) + return; + + const int n = dot_count(); + instance_count = n; + std::vector inst; + inst.resize(n * 3); + + const float w = static_cast(std::max(0, g_state.cols - 1)) * g_state.pitch; + const float h = static_cast(std::max(0, g_state.rows - 1)) * g_state.pitch; + for (int i = 0; i < n; ++i) { + const int r = (g_state.cols > 0) ? (i / g_state.cols) : 0; + const int c = (g_state.cols > 0) ? (i % g_state.cols) : 0; + + const float x = (static_cast(c) * g_state.pitch) - w * 0.5f; + const float y = (static_cast(r) * g_state.pitch) - h * 0.5f; + inst[i * 3 + 0] = x; + inst[i * 3 + 1] = y; + inst[i * 3 + 2] = (i < static_cast(g_state.values.size())) ? g_state.values[i] : static_cast(g_state.minV); + } + + glBindBuffer(GL_ARRAY_BUFFER, instance_vbo); + glBufferSubData(GL_ARRAY_BUFFER, 0, inst.size() * sizeof(float), inst.data()); + g_state.valuesDirty = false; +} + +void update_matrices(int fbWidth, int fbHeight) { + // 计算 MVP:透视投影 * 观察(环绕相机)* 单位模型 + if (fbWidth <= 0 || fbHeight <= 0) { + g_state.mvp = glm::mat4(1.0f); + return; + } + + const float aspect = static_cast(fbWidth) / static_cast(fbHeight); + const float radius = 0.5f * std::sqrt(g_state.panelW * g_state.panelW + g_state.panelD * g_state.panelD); + const float distance = std::max(0.5f, radius * 2.5f); + + // 目标点:面板中心 + const glm::vec3 center(0.0f, 0.0f, g_state.panelD*0.5f); + const float yawRad = glm::radians(g_state.camYaw); + const float pitchRad = glm::radians(g_state.camPitch); + const float cosPitch = std::cos(pitchRad); + + const glm::vec3 eye = center + glm::vec3( + distance * cosPitch * std::cos(yawRad), + distance * std::sin(pitchRad), + distance * cosPitch * std::sin(yawRad) + ); + g_state.cameraPos = eye; + + // 构建视图矩阵与透视矩阵 + const glm::mat4 view = glm::lookAt(eye, center, glm::vec3(0.0f, 1.0f, 0.0f)); + const glm::mat4 proj = glm::perspective(glm::radians(g_state.zoom), aspect, 0.01f, std::max(10.0f, distance * 10.0f)); + g_state.mvp = proj * view; +} + +void render_background(int fbWidth, int fbHeight) { + // 屏幕空间网格,不受相机旋转影响 + if (!bg_shader || !bg_vao) return; + + glDisable(GL_DEPTH_TEST); + glDepthMask(GL_FALSE); + + bg_shader->use(); + bg_shader->setVec2("uViewport", glm::vec2(static_cast(fbWidth), static_cast(fbHeight))); + bg_shader->setFloat("uMinorStep", 24.0f); + bg_shader->setFloat("uMajorStep", 120.0f); + + glBindVertexArray(bg_vao); + glDrawArrays(GL_TRIANGLES, 0, 6); + glBindVertexArray(0); + + glDepthMask(GL_TRUE); + glEnable(GL_DEPTH_TEST); +} + +void render_room() { + // 3D 房间背景:用一个大盒子把场景包起来(相当于“屋子墙壁/地面”) + if (!room_shader || !room_vao) + return; + + // 房间尺寸:根据面板尺寸做一个“够大”的包围盒(单位:世界坐标) + const float base = std::max({g_state.panelW, g_state.panelH, g_state.panelD}); + const glm::vec3 roomHalfSize( + std::max(1.0f, base * 1.1f), // X: 左右墙离中心的距离 + std::max(1.0f, base * 1.1f), // Y: 地面/天花板离中心的距离 + std::max(1.0f, base * 1.1f) // Z: 前后墙离中心的距离 + ); + + // 网格尺寸(让房间更有空间感) + const float minorStep = std::max(0.05f, base * 0.25f); + const float majorStep = minorStep * 5.0f; + + room_shader->use(); + room_shader->setMat4("uMVP", g_state.mvp); + room_shader->setVec3("uCameraPos", g_state.cameraPos); + room_shader->setVec3("uRoomHalfSize", roomHalfSize); + room_shader->setFloat("uMinorStep", minorStep); + room_shader->setFloat("uMajorStep", majorStep); + room_shader->setInt("uRenderMode", g_state.renderMode); + + glBindVertexArray(room_vao); + glDrawElements(GL_TRIANGLES, room_index_count, GL_UNSIGNED_INT, nullptr); + glBindVertexArray(0); +} + +void render_panel() { + // 绘制面板盒子 + if (!panel_shader || !panel_vao) return; + + panel_shader->use(); + panel_shader->setMat4("uMVP", g_state.mvp); + panel_shader->setVec3("uCameraPos", g_state.cameraPos); + panel_shader->setFloat("uPanelW", g_state.panelW); + panel_shader->setFloat("uPanelH", g_state.panelH); + panel_shader->setFloat("uPanelD", g_state.panelD); + panel_shader->setInt("uRows", g_state.rows); + panel_shader->setInt("uCols", g_state.cols); + panel_shader->setFloat("uPitch", g_state.pitch); + panel_shader->setFloat("uDotRadius", g_state.dotRadius); + panel_shader->setInt("uRenderMode", g_state.renderMode); + + glBindVertexArray(panel_vao); + glDrawElements(GL_TRIANGLES, panel_index_count, GL_UNSIGNED_INT, nullptr); + glBindVertexArray(0); +} + +void render_dots() { + // 实例化绘制圆点 + if (!dots_shader || !dots_vao || instance_count <= 0) return; + + dots_shader->use(); + dots_shader->setMat4("uMVP", g_state.mvp); + dots_shader->setInt("uRenderMode", g_state.renderMode); + dots_shader->setFloat("uDotRadius", g_state.dotRadius); + dots_shader->setFloat("uBaseZ", -(g_state.panelD * 0.5f) - 0.001f); + dots_shader->setFloat("uMinV", static_cast(g_state.minV)); + dots_shader->setFloat("uMaxV", static_cast(g_state.maxV)); + dots_shader->setInt("uHasData", 1); + dots_shader->setVec3("uCameraPos", g_state.cameraPos); + dots_shader->setInt("uDotTex", 0); + if (dot_tex) { + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, dot_tex); + } + + glBindVertexArray(dots_vao); + glDrawArraysInstanced(GL_TRIANGLES, 0, 6, instance_count); + glBindVertexArray(0); + + if (dot_tex) { + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, 0); + } +} + +void update_demo_values(float t) { + // 生成演示用波纹数据,保持实例缓冲在动 + const int n = dot_count(); + if (n <= 0) { + instance_count = 0; + return; + } + if (static_cast(g_state.values.size()) != n) { + g_state.values.assign(n, static_cast(g_state.minV)); + } + for (int i = 0; i < n; ++i) { + const float phase = t * 0.6f + static_cast(i) * 0.35f; + const float wave = 0.5f + 0.5f * std::sin(phase); + const float minV = static_cast(g_state.minV); + const float maxV = static_cast(g_state.maxV); + g_state.values[i] = minV + (maxV - minV) * wave; + } + g_state.valuesDirty = true; +} + +void destroy_context() { + delete bg_shader; + delete room_shader; + delete panel_shader; + delete dots_shader; + if (room_vao) glDeleteVertexArrays(1, &room_vao); + if (room_vbo) glDeleteBuffers(1, &room_vbo); + if (room_ibo) glDeleteBuffers(1, &room_ibo); + if (panel_vao) glDeleteVertexArrays(1, &panel_vao); + if (panel_vbo) glDeleteBuffers(1, &panel_vbo); + if (panel_ibo) glDeleteBuffers(1, &panel_ibo); + if (dots_vao) glDeleteVertexArrays(1, &dots_vao); + if (dots_vbo) glDeleteBuffers(1, &dots_vbo); + if (instance_vbo) glDeleteBuffers(1, &instance_vbo); + if (bg_vao) glDeleteVertexArrays(1, &bg_vao); + if (bg_vbo) glDeleteBuffers(1, &bg_vbo); + if (dot_tex) glDeleteTextures(1, &dot_tex); +} + +void load_metal_texture(const std::string& path) { + if (metal_tex) { + glDeleteTextures(1, &metal_tex); + metal_tex = 0; + } + glGenTextures(1, &metal_tex); + glBindTexture(GL_TEXTURE_2D, metal_tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + int width, height, channels; + unsigned char* data = stbi_load(path.c_str(), &width, &height, &channels, 0); + if (data) { + std::cout << "load texture image path: " << path << std::endl; + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data); + glGenerateMipmap(GL_TEXTURE_2D); + glBindTexture(GL_TEXTURE_2D, 0); + } + stbi_image_free(data); } -void set_spec(int rows, int cols, float pitch); int main() { + // 初始化窗口/上下文 GLFWwindow* window = glfw_init(); - - if (window == NULL) { + if (window == nullptr) { return -1; } + glEnable(GL_DEPTH_TEST); + glDepthFunc(GL_LESS); - glfw_window_loop(window); + // 初始化规格与资源 + set_spec(8, 10, 0.025f, 0.008f); + init_programs(); + // init_dot_texture(); + init_room_geometry(); + load_metal_texture("../images/metal.jpeg"); + while (!glfwWindowShouldClose(window)) { + const float currentFrame = static_cast(glfwGetTime()); + process_input(window); + int fbWidth = 0, fbHeight = 0; + glfwGetFramebufferSize(window, &fbWidth, &fbHeight); + glViewport(0, 0, fbWidth, fbHeight); + glClearColor(1.0f, 1.0f, 1.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + if (panel_geometry_dirty) { + init_panel_geometry(); + panel_geometry_dirty = false; + } + if (dot_geometry_dirty) { + init_dot_geometry(); + dot_geometry_dirty = false; + g_state.valuesDirty = true; + } + + update_matrices(fbWidth, fbHeight); + update_demo_values(currentFrame); + update_instance_buffer_if_needed(); + + render_room(); + render_panel(); + render_dots(); + + glfwSwapBuffers(window); + glfwPollEvents(); + } + + destroy_context(); + glfwDestroyWindow(window); glfwTerminate(); return 0; } diff --git a/test/onlygl/myshader.hh b/test/onlygl/myshader.hh index 2392ff8..297da65 100644 --- a/test/onlygl/myshader.hh +++ b/test/onlygl/myshader.hh @@ -6,6 +6,8 @@ #include #include #include +#include +#include class Shader { public: @@ -13,6 +15,7 @@ public: unsigned int ID; // 构造器读取并构建着色器 Shader(const char* vertexPath, const char* fragmentPath) { + std::cout << "begin compile [" << vertexPath << "] and [" << fragmentPath << "] !" << std::endl; std::string vertexCode; std::string fragmentCode; std::ifstream vShaderFile; @@ -70,13 +73,18 @@ public: glLinkProgram(ID); glGetProgramiv(ID, GL_LINK_STATUS, &success); if (!success) { - glGetShaderInfoLog(ID, 512, NULL, infoLog); + glGetProgramInfoLog(ID, 512, NULL, infoLog); std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl; } glDeleteShader(vertex); glDeleteShader(fragment); } + ~Shader() { + if (ID) { + glDeleteProgram(ID); + } + } // 使用/激活程序 void use() { glUseProgram(ID); @@ -91,4 +99,13 @@ public: void setFloat(const std::string& name, float value) const { glUniform1f(glGetUniformLocation(ID, name.c_str()), value); } + void setVec2(const std::string& name, const glm::vec2& value) const { + glUniform2fv(glGetUniformLocation(ID, name.c_str()), 1, glm::value_ptr(value)); + } + void setVec3(const std::string& name, const glm::vec3& value) const { + glUniform3fv(glGetUniformLocation(ID, name.c_str()), 1, glm::value_ptr(value)); + } + void setMat4(const std::string& name, const glm::mat4& value) const { + glUniformMatrix4fv(glGetUniformLocation(ID, name.c_str()), 1, GL_FALSE, glm::value_ptr(value)); + } }; diff --git a/test/onlygl/shaders/bg.frag b/test/onlygl/shaders/bg.frag index d975b7c..2287715 100644 --- a/test/onlygl/shaders/bg.frag +++ b/test/onlygl/shaders/bg.frag @@ -1,15 +1,19 @@ #version 330 core out vec4 FragColor; +// 视口大小(像素,建议传入 framebuffer 尺寸,HiDPI 下要乘 devicePixelRatio) uniform vec2 uViewport; + +// 以像素为单位的网格间距:细网格/粗网格 uniform float uMinorStep; uniform float uMajorStep; +// 生成抗锯齿网格线(返回 0..1,1 表示在线上) float gridLine(float stepPx) { - // 当前坐标像素 vec2 coord = gl_FragCoord.xy; vec2 q = coord / stepPx; + // 距离最近网格线的归一化距离,再用 fwidth 做抗锯齿 vec2 g = abs(fract(q - 0.5) - 0.5) / fwidth(q); float line = 1.0 - min(min(g.x, g.y), 1.0); return line; @@ -17,21 +21,28 @@ float gridLine(float stepPx) { void main() { vec2 viewport = max(uViewport, vec2(1.0)); - vec2 uv = gl_FragCoord.xy / viewport; + vec2 uv = gl_FragCoord.xy / viewport; // 0..1 + // 背景渐变:上更亮、下稍灰,常见 3D 软件的“科技感”底色 vec3 topCol = vec3(0.99, 0.99, 1.00); vec3 botCol = vec3(0.94, 0.95, 0.98); vec3 col = mix(botCol, topCol, uv.y); + // 网格线:细线 + 粗线(每隔一段更深一点) float minor = gridLine(max(uMinorStep, 1.0)); float major = gridLine(max(uMajorStep, 1.0)); + vec3 minorCol = vec3(0.80, 0.82, 0.87); - vec3 majorcol = vec3(0.70, 0.73, 0.80); + vec3 majorCol = vec3(0.70, 0.73, 0.80); + col = mix(col, minorCol, minor * 0.22); - col = mix(col, majorcol, major * 0.35); + col = mix(col, majorCol, major * 0.35); + + // 轻微 vignette(四角略暗),让画面更“聚焦” vec2 p = uv * 2.0 - 1.0; float v = clamp(1.0 - dot(p, p) * 0.12, 0.0, 1.0); col *= mix(1.0, v, 0.35); FragColor = vec4(col, 1.0); } + diff --git a/test/onlygl/shaders/bg.vert b/test/onlygl/shaders/bg.vert index 508dbc6..29aaa5e 100644 --- a/test/onlygl/shaders/bg.vert +++ b/test/onlygl/shaders/bg.vert @@ -1,6 +1,8 @@ #version 330 core -layout(location = 0) in vec2 aPos; +// 全屏背景:直接在裁剪空间画一个矩形(不受相机/旋转影响) +layout(location = 0) in vec2 aPos; // NDC: [-1,1] void main() { gl_Position = vec4(aPos, 0.0, 1.0); -} \ No newline at end of file +} + diff --git a/test/onlygl/shaders/dots.frag b/test/onlygl/shaders/dots.frag new file mode 100644 index 0000000..5373972 --- /dev/null +++ b/test/onlygl/shaders/dots.frag @@ -0,0 +1,165 @@ +#version 330 core +in vec2 vUV; +in float vValue; +in vec3 vWorldPos; +out vec4 FragColor; + +uniform float uMinV; +uniform float uMaxV; +uniform sampler2D uDotTex; +uniform int uHasData; // 0 = no data, 1 = has data +uniform vec3 uCameraPos; +uniform float uDotRadius; +uniform int uRenderMode; // 0=realistic, 1=dataViz + +const float PI = 3.14159265359; + +float saturate(float x) { return clamp(x, 0.0, 1.0); } + +vec3 dataColorRamp(float t) { + t = saturate(t); + vec3 c0 = vec3(0.10, 0.75, 1.00); // cyan-blue (low) + vec3 c1 = vec3(0.10, 0.95, 0.35); // green + vec3 c2 = vec3(1.00, 0.92, 0.22); // yellow + vec3 c3 = vec3(1.00, 0.22, 0.10); // red (high) + + if (t < 0.33) return mix(c0, c1, t / 0.33); + if (t < 0.66) return mix(c1, c2, (t - 0.33) / 0.33); + return mix(c2, c3, (t - 0.66) / 0.34); +} + +vec3 fresnelSchlick(float cosTheta, vec3 F0) { + return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); +} + +float D_GGX(float NdotH, float roughness) { + float a = max(0.04, roughness); + float alpha = a * a; + float alpha2 = alpha * alpha; + float denom = (NdotH * NdotH) * (alpha2 - 1.0) + 1.0; + return alpha2 / (PI * denom * denom + 1e-7); +} + +float G_SchlickGGX(float NdotV, float roughness) { + float r = roughness + 1.0; + float k = (r * r) / 8.0; + return NdotV / (NdotV * (1.0 - k) + k + 1e-7); +} + +float G_Smith(float NdotV, float NdotL, float roughness) { + float ggx1 = G_SchlickGGX(NdotV, roughness); + float ggx2 = G_SchlickGGX(NdotL, roughness); + return ggx1 * ggx2; +} + +float D_GGX_Aniso(vec3 N, vec3 H, vec3 T, vec3 B, float ax, float ay) { + float NdotH = saturate(dot(N, H)); + float TdotH = dot(T, H); + float BdotH = dot(B, H); + float ax2 = ax * ax; + float ay2 = ay * ay; + float denom = (TdotH * TdotH) / (ax2 + 1e-7) + (BdotH * BdotH) / (ay2 + 1e-7) + NdotH * NdotH; + return 1.0 / (PI * ax * ay * denom * denom + 1e-7); +} + +vec3 evalLight( + vec3 N, + vec3 V, + vec3 L, + vec3 lightColor, + vec3 baseColor, + float metallic, + float roughness, + float aniso, + vec3 brushDir +) { + float NdotL = saturate(dot(N, L)); + float NdotV = saturate(dot(N, V)); + if (NdotL <= 0.0 || NdotV <= 0.0) return vec3(0.0); + + vec3 H = normalize(V + L); + float NdotH = saturate(dot(N, H)); + float VdotH = saturate(dot(V, H)); + + vec3 F0 = mix(vec3(0.04), baseColor, metallic); + vec3 F = fresnelSchlick(VdotH, F0); + + float D = D_GGX(NdotH, roughness); + if (aniso > 0.001) { + vec3 T = normalize(brushDir - N * dot(brushDir, N)); + vec3 B = normalize(cross(N, T)); + float alpha = max(0.04, roughness); + float a = alpha * alpha; + float ax = mix(a, a * 0.30, aniso); + float ay = mix(a, a * 2.00, aniso); + D = D_GGX_Aniso(N, H, T, B, ax, ay); + } + + float G = G_Smith(NdotV, NdotL, roughness); + vec3 spec = (D * G * F) / max(4.0 * NdotV * NdotL, 1e-6); + + vec3 kD = (vec3(1.0) - F) * (1.0 - metallic); + vec3 diff = kD * baseColor / PI; + + return (diff + spec) * lightColor * NdotL; +} + +void main() { + vec2 p = vUV * 2.0 - 1.0; + float r = length(p); + if (r > 1.0) discard; + float r01 = saturate(r); + + // Industrial engineering model: simple plated metal pad (brass/gold-ish). + // When no data, keep a bright gold base. When data is present, render the + // data color directly (no remaining gold tint), while preserving depth cues. + vec3 metalBase = vec3(0.98, 0.82, 0.30); + float value01 = clamp((vValue - uMinV) / max(1e-6, (uMaxV - uMinV)), 0.0, 1.0); + vec3 dataCol = dataColorRamp(value01); + + // bool hasData = (uHasData != 0); + // vec3 baseColor = hasData ? dataCol : metalBase; + vec3 baseColor = metalBase; + + // dataViz: flat/unlit, no lighting modulation (keep pure baseColor) + if (uRenderMode == 1) { + FragColor = vec4(clamp(baseColor, 0.0, 1.0), 1.0); + return; + } + + // Mostly flat, with a slight bevel near the edge to catch highlights. + float slope = mix(0.06, 0.28, smoothstep(0.55, 1.0, r01)); + // Face the camera: dots live on the panel front face (XY plane), so the base normal points -Z. + // vec3 N = normalize(vec3(p.x * slope, p.y * slope, -1.0)); + vec3 N = normalize(vec3(0.0, 0.15, -1.0)); + vec3 V = normalize(uCameraPos - vWorldPos); + + // float metallic = hasData ? 0.0 : 0.90; + // float roughness = hasData ? 0.78 : ((uRenderMode == 1) ? 0.70 : 0.55); + float metallic = 0.90; + float roughness = 0.55; + + // "Front light": make the light come from the camera direction (like a headlight/flashlight). + vec3 keyL = V; + vec3 fillL = V; + vec3 keyC = vec3(1.00, 0.98, 0.95) * 1.8; + vec3 fillC = vec3(0.85, 0.90, 1.00) * 0.9; + + vec3 Lo = vec3(0.0); + Lo += evalLight(N, V, keyL, keyC, baseColor, metallic, roughness, 0.0, vec3(1.0, 0.0, 0.0)); + Lo += evalLight(N, V, fillL, fillC, baseColor, metallic, roughness, 0.0, vec3(1.0, 0.0, 0.0)); + + vec3 F0 = mix(vec3(0.04), baseColor, metallic); + vec3 ambient = baseColor * 0.10 + F0 * 0.04; + + float edgeAO = smoothstep(0.88, 1.0, r01); + float ao = 1.0 - edgeAO * 0.10; + + // Subtle boundary ring (engineering-model crispness, not a UI outline). + float ring = smoothstep(0.82, 0.92, r01) - smoothstep(0.92, 1.00, r01); + + vec3 col = (ambient + Lo) * ao; + col = mix(col, col * 0.82, ring * 0.35); + + FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); +} diff --git a/test/onlygl/shaders/dots.vert b/test/onlygl/shaders/dots.vert new file mode 100644 index 0000000..724771c --- /dev/null +++ b/test/onlygl/shaders/dots.vert @@ -0,0 +1,31 @@ +#version 330 core + +layout(location = 0) in vec2 qQuadPos; // 单位 quad 的局部顶点坐标(范围 [-1,1]) +layout(location = 1) in vec2 aUV; // UV(用于 fragment shader 把 quad 变成圆形) + +layout(location = 2) in vec2 iOffsetXZ; // 每个点的偏移(世界坐标 XZ) +layout(location = 3) in float iValue; // 每个点的数值(用于颜色映射) + +out vec2 vUV; +out float vValue; +out vec3 vWorldPos; + +uniform mat4 uMVP; // Projection * View * Model(这里 Model 约等于单位矩阵) +uniform float uDotRadius; // dot 半径(世界坐标单位) +uniform float uBaseZ; // dot 的高度(通常 = panel 顶面 y + 一点点偏移) + +void main() { + vUV = aUV; + vValue = iValue; + + // 先确定 dot 的中心点(世界坐标) + vec3 world = vec3(iOffsetXZ.x, iOffsetXZ.y, uBaseZ); + + // 再把单位 quad 按半径缩放并加到中心点上(让 quad 落在 XZ 平面) + world.x += qQuadPos.x * uDotRadius; + world.y += qQuadPos.y * uDotRadius; + + // 输出裁剪空间坐标(最终会进行透视除法与视口映射,变成屏幕上的像素) + vWorldPos = world; + gl_Position = uMVP * vec4(world, 1.0); +} diff --git a/test/onlygl/shaders/panel.frag b/test/onlygl/shaders/panel.frag new file mode 100644 index 0000000..43b3c0d --- /dev/null +++ b/test/onlygl/shaders/panel.frag @@ -0,0 +1,191 @@ +#version 330 core +in vec3 vWorldPos; +in vec3 vWorldNormal; +out vec4 FragColor; + +uniform vec3 uCameraPos; +uniform float uPanelW; +uniform float uPanelH; +uniform float uPanelD; +uniform int uRows; +uniform int uCols; +uniform float uPitch; +uniform float uDotRadius; +uniform int uRenderMode; // 0=realistic, 1=dataViz + +const float PI = 3.14159265359; + +float saturate(float x) { return clamp(x, 0.0, 1.0); } + +float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +float noise2d(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + float a = hash12(i); + float b = hash12(i + vec2(1.0, 0.0)); + float c = hash12(i + vec2(0.0, 1.0)); + float d = hash12(i + vec2(1.0, 1.0)); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; +} + +float fbm(vec2 p) { + float v = 0.0; + float a = 0.5; + for (int i = 0; i < 4; ++i) { + v += a * noise2d(p); + p *= 2.0; + a *= 0.5; + } + return v; +} + +vec3 fresnelSchlick(float cosTheta, vec3 F0) { + return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0); +} + +float D_GGX(float NdotH, float roughness) { + float a = max(0.04, roughness); + float alpha = a * a; + float alpha2 = alpha * alpha; + float denom = (NdotH * NdotH) * (alpha2 - 1.0) + 1.0; + return alpha2 / (PI * denom * denom + 1e-7); +} + +float G_SchlickGGX(float NdotV, float roughness) { + float r = roughness + 1.0; + float k = (r * r) / 8.0; + return NdotV / (NdotV * (1.0 - k) + k + 1e-7); +} + +float G_Smith(float NdotV, float NdotL, float roughness) { + float ggx1 = G_SchlickGGX(NdotV, roughness); + float ggx2 = G_SchlickGGX(NdotL, roughness); + return ggx1 * ggx2; +} + +float D_GGX_Aniso(vec3 N, vec3 H, vec3 T, vec3 B, float ax, float ay) { + float NdotH = saturate(dot(N, H)); + float TdotH = dot(T, H); + float BdotH = dot(B, H); + float ax2 = ax * ax; + float ay2 = ay * ay; + float denom = (TdotH * TdotH) / (ax2 + 1e-7) + (BdotH * BdotH) / (ay2 + 1e-7) + NdotH * NdotH; + return 1.0 / (PI * ax * ay * denom * denom + 1e-7); +} + +vec3 evalLight( + vec3 N, + vec3 V, + vec3 L, + vec3 lightColor, + vec3 baseColor, + float metallic, + float roughness, + float aniso, + vec3 brushDir +) { + float NdotL = saturate(dot(N, L)); + float NdotV = saturate(dot(N, V)); + if (NdotL <= 0.0 || NdotV <= 0.0) return vec3(0.0); + + vec3 H = normalize(V + L); + float NdotH = saturate(dot(N, H)); + float VdotH = saturate(dot(V, H)); + + vec3 F0 = mix(vec3(0.04), baseColor, metallic); + vec3 F = fresnelSchlick(VdotH, F0); + + float D = D_GGX(NdotH, roughness); + if (aniso > 0.001) { + vec3 T = normalize(brushDir - N * dot(brushDir, N)); + vec3 B = normalize(cross(N, T)); + float alpha = max(0.04, roughness); + float a = alpha * alpha; + float ax = mix(a, a * 0.35, aniso); + float ay = mix(a, a * 1.80, aniso); + D = D_GGX_Aniso(N, H, T, B, ax, ay); + } + + float G = G_Smith(NdotV, NdotL, roughness); + vec3 spec = (D * G * F) / max(4.0 * NdotV * NdotL, 1e-6); + + vec3 kD = (vec3(1.0) - F) * (1.0 - metallic); + vec3 diff = kD * baseColor / PI; + + return (diff + spec) * lightColor * NdotL; +} + +float nearestDotDistanceXZ(vec2 xz) { + if (uPitch <= 0.0 || uRows <= 0 || uCols <= 0) return 1e6; + int colsM1 = max(uCols - 1, 0); + int rowsM1 = max(uRows - 1, 0); + float halfGridW = float(colsM1) * uPitch * 0.5; + float halfGridD = float(rowsM1) * uPitch * 0.5; + + vec2 g = (xz + vec2(halfGridW, halfGridD)) / max(1e-6, uPitch); + vec2 gi = floor(g + 0.5); + gi = clamp(gi, vec2(0.0), vec2(float(colsM1), float(rowsM1))); + + vec2 c = gi * uPitch - vec2(halfGridW, halfGridD); + return length(xz - c); +} + +void main() { + // panel 先用一个固定颜色(后续可以加光照/材质) + vec3 N = normalize(vWorldNormal); + vec3 V = normalize(uCameraPos - vWorldPos); + + float isTop = step(0.75, N.y); + + // ------------------------------------------------------------ + // Industrial engineering model: neutral matte gray panel (support layer only) + // ------------------------------------------------------------ + vec3 topBase = vec3(0.30, 0.31, 0.32); + vec3 sideBase = vec3(0.27, 0.28, 0.29); + vec3 baseColor = mix(sideBase, topBase, isTop); + + // dataViz: flat/unlit, no lighting modulation (keep pure baseColor) + if (uRenderMode == 1) { + FragColor = vec4(clamp(baseColor, 0.0, 1.0), 1.0); + return; + } + + vec2 xz = vWorldPos.xz; + float dotContact = 0.0; + if (isTop > 0.5 && uDotRadius > 0.0) { + float d = nearestDotDistanceXZ(xz); + float w = max(0.002, uDotRadius * 0.22); + dotContact = 1.0 - smoothstep(uDotRadius, uDotRadius + w, d); + } + + // "Front light": make the light come from the camera direction (like a headlight/flashlight). + vec3 L = V; + // L = normalize(vec3(0.0, 0.15, -1.0)); + float diff = saturate(dot(N, L)); + float lighting = 0.90 + 0.10 * diff; + + float hw = max(1e-6, uPanelW * 0.5); + float hd = max(1e-6, uPanelD * 0.5); + float edgeDist = min(hw - abs(vWorldPos.x), hd - abs(vWorldPos.z)); + float edgeW = max(0.002, min(hw, hd) * 0.012); + float edgeLine = (1.0 - smoothstep(edgeW, edgeW * 2.5, edgeDist)) * isTop; + + float rim = pow(1.0 - saturate(dot(N, V)), 2.2) * isTop; + float ao = 1.0 - dotContact * 0.08; + + vec3 col = baseColor * lighting * ao; + col += edgeLine * vec3(0.020); + col += rim * vec3(0.015); + + // Slightly deepen the bottom face to read as thickness, but keep it subtle. + float isBottom = step(0.75, -N.y); + col *= mix(1.0, 0.92, isBottom); + + FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); +} diff --git a/test/onlygl/shaders/panel.vert b/test/onlygl/shaders/panel.vert new file mode 100644 index 0000000..16f310b --- /dev/null +++ b/test/onlygl/shaders/panel.vert @@ -0,0 +1,18 @@ +#version 330 core +// 顶点输入(来自 VBO) +layout(location=0) in vec3 aPos; // 顶点位置(当前我们直接当作“世界坐标”来用) +layout(location=1) in vec3 aN; // 法线(当前没用到,先保留) + +// uMVP = Projection * View * Model +// 把顶点从“世界坐标”变换到“裁剪空间(clip space)”,OpenGL 用 gl_Position 来完成屏幕投影 +out vec3 vWorldPos; +out vec3 vWorldNormal; + +uniform mat4 uMVP; + +void main() { + // Model is identity in this project; treat vertex data as world space. + vWorldPos = aPos; + vWorldNormal = aN; + gl_Position = uMVP * vec4(aPos, 1.0); +} diff --git a/test/onlygl/shaders/room.frag b/test/onlygl/shaders/room.frag new file mode 100644 index 0000000..50d2047 --- /dev/null +++ b/test/onlygl/shaders/room.frag @@ -0,0 +1,90 @@ +#version 330 core + +in vec3 vWorldPos; +in vec3 vWorldNormal; +out vec4 FragColor; + +uniform vec3 uCameraPos; +uniform vec3 uRoomHalfSize; +uniform float uMinorStep; +uniform float uMajorStep; +uniform int uRenderMode; // 0=realistic, 1=dataViz (flat/unlit) + +float saturate(float x) { + return clamp(x, 0.0, 1.0); +} + +float gridLine(vec2 coord, float stepSize) { + stepSize = max(stepSize, 1e-4); + vec2 q = coord / stepSize; + vec2 g = abs(fract(q - 0.5) - 0.5) / fwidth(q); + return 1.0 - min(min(g.x, g.y), 1.0); +} + +vec2 pickGridPlane(vec3 N, vec3 P) { + // 根据朝向选择在哪个平面画网格: + // - 地面/天花板(法线接近 ±Y):用 XZ + // - 前后墙(法线接近 ±Z):用 XY + // - 左右墙(法线接近 ±X):用 ZY + vec3 a = abs(N); + if (a.y > a.x && a.y > a.z) return P.xz; + if (a.z > a.x) return P.xy; + return P.zy; +} + +void main() { + vec3 N = normalize(vWorldNormal); + vec3 V = normalize(uCameraPos - vWorldPos); + + // 区分地面/天花板/墙面配色(简单做个“房间感”) + float isFloor = step(0.8, N.y); + float isCeil = step(0.8, -N.y); + vec3 floorCol = vec3(0.90, 0.90, 0.92); + vec3 wallCol = vec3(0.96, 0.96, 0.98); + vec3 ceilCol = vec3(0.98, 0.98, 1.00); + vec3 baseCol = wallCol; + baseCol = mix(baseCol, floorCol, isFloor); + baseCol = mix(baseCol, ceilCol, isCeil); + + // 在不同面上画网格:小格 + 大格 + vec2 plane = pickGridPlane(N, vWorldPos); + float minor = gridLine(plane, uMinorStep); + float major = gridLine(plane, uMajorStep); + vec3 minorCol = vec3(0.78, 0.80, 0.85); + vec3 majorCol = vec3(0.68, 0.70, 0.77); + baseCol = mix(baseCol, minorCol, minor * 0.18); + baseCol = mix(baseCol, majorCol, major * 0.28); + + // dataViz: flat/unlit, no lighting modulation (keep pure baseCol + grid) + if (uRenderMode == 1) { + FragColor = vec4(clamp(baseCol, 0.0, 1.0), 1.0); + return; + } + + // 简单两盏灯:主光 + 补光(够用就好) + // "Front light": make the light come from the camera direction (like a headlight/flashlight). + vec3 keyL = V; + vec3 fillL = V; + float diff1 = max(dot(N, keyL), 0.0); + float diff2 = max(dot(N, fillL), 0.0); + float lighting = 0.65 + 0.25 * diff1 + 0.10 * diff2; + + // 角落稍微压暗,增强“箱体/房间”感觉 + vec3 p = abs(vWorldPos / max(uRoomHalfSize, vec3(1e-4))); + float corner = pow(max(p.x, max(p.y, p.z)), 6.0); + float cornerDark = mix(1.0, 0.80, corner); + + // 轻微雾化:远处更亮一点点,让边界更柔和 + float dist = length(uCameraPos - vWorldPos); + float fog = exp(-dist * 0.06); + vec3 fogCol = vec3(0.985, 0.987, 0.995); + + vec3 col = baseCol * lighting * cornerDark; + col = mix(fogCol, col, saturate(fog)); + + // 增加一点边缘轮廓(靠观察方向) + float rim = pow(1.0 - saturate(dot(N, V)), 2.0); + col += rim * 0.04; + + FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); +} diff --git a/test/onlygl/shaders/room.vert b/test/onlygl/shaders/room.vert new file mode 100644 index 0000000..8ab18f6 --- /dev/null +++ b/test/onlygl/shaders/room.vert @@ -0,0 +1,19 @@ +#version 330 core + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aN; + +out vec3 vWorldPos; +out vec3 vWorldNormal; + +uniform mat4 uMVP; +uniform vec3 uRoomHalfSize; + +void main() { + // 把单位立方体 [-1,1] 缩放成房间大小 + vec3 world = aPos * uRoomHalfSize; + vWorldPos = world; + vWorldNormal = aN; + gl_Position = uMVP * vec4(world, 1.0); +} +