完成主要交互、高性能组件、国际化和A型传感器数据包接收

This commit is contained in:
2026-01-13 16:34:28 +08:00
parent 47e6dc7244
commit 1960e6a5b9
84 changed files with 7752 additions and 332 deletions

8
.idea/.gitignore generated vendored
View File

@@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -1,4 +1,4 @@
cmake_minimum_required(VERSION 4.0) cmake_minimum_required(VERSION 3.21)
project(TactileIpc3D LANGUAGES CXX) project(TactileIpc3D LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
@@ -7,6 +7,8 @@ set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON) set(CMAKE_AUTOUIC ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src) include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src)
find_package(Qt6 COMPONENTS find_package(Qt6 COMPONENTS
@@ -15,7 +17,12 @@ find_package(Qt6 COMPONENTS
Widgets Widgets
QuickWidgets QuickWidgets
OpenGLWidgets OpenGLWidgets
SerialPort
REQUIRED REQUIRED
Quick
QuickControls2
QuickLayouts
LinguistTools
) )
qt_standard_project_setup() qt_standard_project_setup()
@@ -23,17 +30,78 @@ qt_standard_project_setup()
add_executable(TactileIpc3D add_executable(TactileIpc3D
main.cpp main.cpp
resources.qrc resources.qrc
src/translation_manager.h
src/translation_manager.cpp
src/backend.h src/backend.h
src/backend.cpp src/backend.cpp
src/data_backend.h
src/data_backend.cpp
src/data_frame.h
src/glwidget.cpp src/glwidget.cpp
src/glwidget.h 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 target_link_libraries(TactileIpc3D
Qt::Core Qt6::Core
Qt::Gui Qt6::Gui
Qt::Widgets Qt6::Widgets
Qt::QuickWidgets Qt6::QuickWidgets
Qt::OpenGLWidgets 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) #if (WIN32 AND NOT DEFINED CMAKE_TOOLCHAIN_FILE)

74
Prompt.md Normal file
View File

@@ -0,0 +1,74 @@
我在构建一款触觉传感器的工业上位机主要用到了qmlopengl。我下面会说明我的需求。
## ui
ui方面我希望使用1+3的模式最上面是nav导航条暂定有Title和lightmode、language。可以直观的展示名字、明暗和语言。ui风格使用qml中的material.green风格。
下面是三栏左边是panel控制传感器的连接opengl的展示和量程规格等数据这个我希望可以支持补充我已经做好了一个LeftPanel.qml你可以参考。中间是opengl对传感器的3D渲染。右边是一些可视化指标折线图等。
## 整体设计
我希望可以对软件进行分层设计有统一的后端AppBackend。这个AppBackend驱动其他功能组件。
首先是串口数据采集层我希望可以抽象为codecdecodecformatmanager类似ffmpeg的设计来适配不同协议的传感器。传感器有从站模式和主站模式你可以抽象为一个统一个config在界面上配置参数地址应该是一个十六进制的值类似0x01我觉得使用输入框比较好如果是从站模式也就是需要发送指令才能获取数据的需要有一个参数配置采样频率也就是多少时间发一次request。我希望串口层是一个独立的层他不负责业务功能只提供基础的采集和slave功能。每个编解码器需要提供对应的接口这里你需要预留一些接口让我来填充比如获取版本号获取传感器规格request数据。解码器负责将接受到的一帧数据packet解码数据形成frame格式frame中需要有pts年月日时分妙毫秒202601011208xxx24小时制、功能码、数据比如3*4规格就要有12个数据
为了避免采集功能造成ui卡顿需要分别创建读取线程解码线程发送线程类比ffmpeg的开发将frame和packet存储在一个队列里面解码线程解码结束后将解码好的frame送入数据驱动层。如果是从站模式那么request的编码和发送应该在发送线程进行编码和发送。我需要你完整的编写基于三个线程的编解码函数包括如何送入数据驱动层和导出。考虑到可能有多种编码器我希望你在串口类中将几个函数抽象为function比如requestparse等等,然后由对应的解码器类来提供实际的函数类似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 | 返回数据字节数 N2字节 | 小端 |
| 状态 | 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. 每一次的修改都需要在文档中有说明和体现

View File

@@ -257,3 +257,79 @@ m_dotsProg->release();
- 想要更柔和的圆边:用 `smoothstep` 做 alpha 边缘 + 开启 blending - 想要更柔和的圆边:用 `smoothstep` 做 alpha 边缘 + 开启 blending
- 想要真正的 3D 圆柱/球:就不能靠 discard 了,需要真正的几何或法线光照 - 想要真正的 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.

10
deploy.sh Normal file
View File

@@ -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

534
docs/ARCHITECTURE.md Normal file
View File

@@ -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 {
<<interface>>
+open(config, error)
+close()
+writeBytes(data, error)
}
class QtSerialTransport {
+open(config, error)
+close()
+writeBytes(data, error)
}
class ISerialFormat {
<<interface>>
+tryParse(buffer, packet, error)
}
class ISerialCodec {
<<interface>>
+buildRequest(config, request)
+buildGetVersionRequest(config)
+buildGetSpecRequest(config)
}
class ISerialDecoder {
<<interface>>
+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
- CRCCRC-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-05CollapsiblePanel 组件改为跟随 `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 交互说明。

7
i18n.qrc Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE RCC>
<RCC version="1.0">
<qresource prefix="/i18n">
<file>i18n/app_en_US.ts</file>
<file>i18n/app_zh_CN.ts</file>
</qresource>
</RCC>

298
i18n/app_en_US.ts Normal file
View File

@@ -0,0 +1,298 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="en_US">
<context>
<name>NavBar</name>
<message>
<source>CONNECTED</source>
<translation>CONNECTED</translation>
</message>
<message>
<source>DISCONNECTED</source>
<translation>DISCONNECTED</translation>
</message>
<message>
<source>Light</source>
<translation>Light</translation>
</message>
<message>
<source>Dark</source>
<translation>Dark</translation>
</message>
</context>
<context>
<name>ControlPanel</name>
<message>
<source>Dark mode</source>
<translation>Dark mode</translation>
</message>
<message>
<source>Render</source>
<translation>Render</translation>
</message>
<message>
<source>Mode</source>
<translation>Mode</translation>
</message>
<message>
<source>Labels</source>
<translation>Labels</translation>
</message>
<message>
<source>Legend</source>
<translation>Legend</translation>
</message>
<message>
<source>Scale</source>
<translation>Scale</translation>
</message>
<message>
<source>Min</source>
<translation>Min</translation>
</message>
<message>
<source>Max</source>
<translation>Max</translation>
</message>
</context>
<context>
<name>LabeledSlider</name>
<message>
<source>Text</source>
<translation>Text</translation>
</message>
</context>
<context>
<name>LeftPanel</name>
<message>
<source></source>
<translation>Connection Settings</translation>
</message>
<message>
<source>COM Port</source>
<translation>COM Port</translation>
</message>
<message>
<source>Baud</source>
<translation>Baud</translation>
</message>
<message>
<source></source>
<translation>Mode</translation>
</message>
<message>
<source></source>
<translation>Slave</translation>
</message>
<message>
<source></source>
<translation>Master</translation>
</message>
<message>
<source></source>
<translation>Device Address</translation>
</message>
<message>
<source></source>
<translation>Sample Interval</translation>
</message>
<message>
<source></source>
<translation>Connect</translation>
</message>
<message>
<source></source>
<translation>Disconnect</translation>
</message>
<message>
<source></source>
<translation>Sampling</translation>
</message>
<message>
<source></source>
<translation>Function Code</translation>
</message>
<message>
<source></source>
<translation>Start Address</translation>
</message>
<message>
<source></source>
<translation>Read Length</translation>
</message>
<message>
<source></source>
<translation>Sensor Specs</translation>
</message>
<message>
<source></source>
<translation>Protocol</translation>
</message>
<message>
<source></source>
<translation>Model</translation>
</message>
<message>
<source></source>
<translation>Spec</translation>
</message>
<message>
<source></source>
<translation>Rescan</translation>
</message>
<message>
<source></source>
<translation>Display</translation>
</message>
<message>
<source></source>
<translation>Show Grid</translation>
</message>
<message>
<source></source>
<translation>Show Axes</translation>
</message>
<message>
<source></source>
<translation>Playback Data</translation>
</message>
<message>
<source></source>
<translation>Export Data</translation>
</message>
</context>
<context>
<name>RightPanel</name>
<message>
<source>Payload Sum</source>
<translation>Payload Sum</translation>
</message>
<message>
<source>Live Trend</source>
<translation>Live Trend</translation>
</message>
<message>
<source>Metrics</source>
<translation>Metrics</translation>
</message>
<message>
<source></source>
<translation>Peak</translation>
</message>
<message>
<source></source>
<translation>RMS</translation>
</message>
<message>
<source></source>
<translation>Average</translation>
</message>
<message>
<source></source>
<translation>Delta</translation>
</message>
<message>
<source>Session</source>
<translation>Session</translation>
</message>
<message>
<source>Frames</source>
<translation>Frames</translation>
</message>
<message>
<source>Playback</source>
<translation>Playback</translation>
</message>
<message>
<source>Running</source>
<translation>Running</translation>
</message>
<message>
<source>Idle</source>
<translation>Idle</translation>
</message>
</context>
<context>
<name>SaveAsExportDialog</name>
<message>
<source></source>
<translation>Export Data</translation>
</message>
<message>
<source></source>
<translation>Search in this location</translation>
</message>
<message>
<source></source>
<translation>Locations</translation>
</message>
<message>
<source></source>
<translation>This PC</translation>
</message>
<message>
<source></source>
<translation>Desktop</translation>
</message>
<message>
<source></source>
<translation>Documents</translation>
</message>
<message>
<source></source>
<translation>Downloads</translation>
</message>
<message>
<source></source>
<translation>Name</translation>
</message>
<message>
<source></source>
<translation>Modified</translation>
</message>
<message>
<source></source>
<translation>File name</translation>
</message>
<message>
<source></source>
<translation>Enter a file name</translation>
</message>
<message>
<source></source>
<translation>File type</translation>
</message>
<message>
<source></source>
<translation>Export mode</translation>
</message>
<message>
<source></source>
<translation>Overwrite (replace)</translation>
</message>
<message>
<source></source>
<translation>Append (same file)</translation>
</message>
<message>
<source>zip</source>
<translation>Compressed (zip)</translation>
</message>
<message>
<source></source>
<translation>Cancel</translation>
</message>
<message>
<source></source>
<translation>Save</translation>
</message>
<message>
<source></source>
<translation>File exists</translation>
</message>
<message>
<source></source>
<translation>The file already exists. Overwrite?</translation>
</message>
</context>
</TS>

298
i18n/app_zh_CN.ts Normal file
View File

@@ -0,0 +1,298 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="zh_CN">
<context>
<name>NavBar</name>
<message>
<source>CONNECTED</source>
<translation></translation>
</message>
<message>
<source>DISCONNECTED</source>
<translation></translation>
</message>
<message>
<source>Light</source>
<translation></translation>
</message>
<message>
<source>Dark</source>
<translation></translation>
</message>
</context>
<context>
<name>ControlPanel</name>
<message>
<source>Dark mode</source>
<translation></translation>
</message>
<message>
<source>Render</source>
<translation></translation>
</message>
<message>
<source>Mode</source>
<translation></translation>
</message>
<message>
<source>Labels</source>
<translation></translation>
</message>
<message>
<source>Legend</source>
<translation></translation>
</message>
<message>
<source>Scale</source>
<translation></translation>
</message>
<message>
<source>Min</source>
<translation></translation>
</message>
<message>
<source>Max</source>
<translation></translation>
</message>
</context>
<context>
<name>LabeledSlider</name>
<message>
<source>Text</source>
<translation></translation>
</message>
</context>
<context>
<name>LeftPanel</name>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source>COM Port</source>
<translation></translation>
</message>
<message>
<source>Baud</source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
</context>
<context>
<name>RightPanel</name>
<message>
<source>Payload Sum</source>
<translation></translation>
</message>
<message>
<source>Live Trend</source>
<translation></translation>
</message>
<message>
<source>Metrics</source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source>Session</source>
<translation></translation>
</message>
<message>
<source>Frames</source>
<translation></translation>
</message>
<message>
<source>Playback</source>
<translation></translation>
</message>
<message>
<source>Running</source>
<translation></translation>
</message>
<message>
<source>Idle</source>
<translation></translation>
</message>
</context>
<context>
<name>SaveAsExportDialog</name>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source>zip</source>
<translation>zip</translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
<message>
<source></source>
<translation></translation>
</message>
</context>
</TS>

BIN
images/china.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 B

BIN
images/computer_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 B

BIN
images/computer_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

BIN
images/desktop_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

BIN
images/desktop_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B

BIN
images/docs_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

BIN
images/docs_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 B

BIN
images/download_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

BIN
images/download_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

BIN
images/folder_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

BIN
images/folder_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

BIN
images/united-states.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 B

166
main.cpp
View File

@@ -1,74 +1,174 @@
#include <QApplication> #include <QApplication>
#include <QPushButton> #include <QColor>
#include <QMainWindow> #include <QMainWindow>
#include <QOffscreenSurface>
#include <QOpenGLContext>
#include <QSplitter> #include <QSplitter>
#include <QQuickWidget> #include <QQuickWidget>
#include <QQmlContext> #include <QQmlEngine>
#include <QQuickWindow> #include <QQuickWindow>
#include <QtQml/qqml.h>
#include <QSGRendererInterface> #include <QSGRendererInterface>
#include <QTimer> #include <QSurfaceFormat>
#include <QRandomGenerator> #include <QVBoxLayout>
#include <QWidget>
#include "backend.h" #include <qdebug.h>
#include <qforeach.h>
#include <qobject.h>
#include <qquickitem.h>
#include <QQuickStyle>
#include <qquickstyle.h>
#include "sparkline_plotitem.h"
#include "backend.h" #include "backend.h"
#include "data_frame.h"
#include "glwidget.h" #include "glwidget.h"
#include "translation_manager.h"
#include "src/sparkline_plotitem.h"
int main(int argc, char *argv[]) { int main(int argc, char *argv[]) {
QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL); QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL);
// 统一OpenGL格式 // 统一OpenGL格式
QSurfaceFormat fmt; QSurfaceFormat fmt;
fmt.setRenderableType(QSurfaceFormat::OpenGL);
fmt.setVersion(3, 3); fmt.setVersion(3, 3);
fmt.setProfile(QSurfaceFormat::CoreProfile); fmt.setProfile(QSurfaceFormat::CoreProfile);
fmt.setDepthBufferSize(24); fmt.setDepthBufferSize(24);
fmt.setStencilBufferSize(8); fmt.setStencilBufferSize(8);
fmt.setSamples(4);
fmt.setSwapInterval(1); fmt.setSwapInterval(1);
QSurfaceFormat::setDefaultFormat(fmt); QSurfaceFormat::setDefaultFormat(fmt);
QApplication a(argc, argv); 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 *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<SparklinePlotItem>("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; auto *splitter = new QSplitter;
splitter->setOrientation(Qt::Horizontal); splitter->setOrientation(Qt::Horizontal);
auto *quick = new QQuickWidget; splitter->setHandleWidth(1);
quick->setResizeMode(QQuickWidget::SizeRootObjectToView);
Backend backend; auto *leftView = createQuickWidget(QUrl("qrc:/qml/content/LeftPanel.qml"));
quick->rootContext()->setContextProperty("backend", &backend); leftView->setFixedWidth(350);
quick->setSource(QUrl("qrc:/qml/Main.qml"));
auto *glw = new GLWidget; auto *glw = new GLWidget;
glw->setSpec(8, 11, 0.1f, 0.03f); glw->setSpec(8, 11, 0.1f, 0.03f);
glw->setPanelThickness(0.08f); glw->setPanelThickness(0.08f);
glw->setRange(backend.minValue(), backend.maxValue()); glw->setRange(0, 1000);
glw->setRenderModeString(backend.renderMode());
glw->setLabelModeString(backend.labelMode());
QObject::connect(&backend, &Backend::rangeChanged, glw, &GLWidget::setRange); /* backend.data()->setLiveRenderCallback([glw](const DataFrame& frame) {
QObject::connect(&backend, &Backend::renderModeValueChanged, glw, &GLWidget::setRenderModeString); if (frame.data.size() != glw->dotCount())
QObject::connect(&backend, &Backend::labelModeValueChanged, glw, &GLWidget::setLabelModeString); 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<float> 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(glw);
splitter->addWidget(rightView);
splitter->setStretchFactor(0, 0); splitter->setStretchFactor(0, 0);
splitter->setStretchFactor(1, 1); splitter->setStretchFactor(1, 1);
win->setCentralWidget(splitter); splitter->setStretchFactor(2, 0);
win->resize(1100, 650); 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(); win->show();
t->start();
return QApplication::exec(); return QApplication::exec();
} }

View File

@@ -1,6 +1,5 @@
import QtQuick import QtQuick
import "content" import "./content"
App { App {
} }

View File

@@ -1,18 +1,56 @@
import QtQuick import QtQuick
import QtQuick.Window
import QtQuick.Controls.Material import QtQuick.Controls.Material
import QtQuick.Layouts
import "./" import "./"
import TactileIPC 1.0
Rectangle { Item {
// width: Constants.width id: root
// height: Constants.height width: 1280
width: 360 height: 720
// minimumWidth: 800
// minimumHeight: 600
visible: true Material.theme: Backend.lightMode ? Material.Light : Material.Dark
Material.accent: Material.Green
Material.primary: Material.Green
ControlPanel { ColumnLayout {
anchors.fill: parent 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
}
}
} }
} }

View File

@@ -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
}
}
}

View File

@@ -1,10 +1,10 @@
import QtQuick import QtQuick
import QtQuick3D
import QtQuick.Controls.Material import QtQuick.Controls.Material
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import QtQml import QtQml
import "." import "."
import TactileIPC 1.0
Pane { Pane {
id: root id: root
@@ -21,11 +21,17 @@ Pane {
Toggle { Toggle {
id: darkModeToggle id: darkModeToggle
text: qsTr("Dark mode") text: Qt.binding(function() {
I18n.retranslateToken
return qsTr("Dark mode")
})
} }
GroupBox { GroupBox {
title: qsTr("Render") title: Qt.binding(function() {
I18n.retranslateToken
return qsTr("Render")
})
Layout.fillWidth: true Layout.fillWidth: true
ColumnLayout { ColumnLayout {
@@ -34,17 +40,23 @@ Pane {
RowLayout { RowLayout {
Layout.fillWidth: true 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 { ComboBox {
id: renderModeBox id: renderModeBox
Layout.fillWidth: true Layout.fillWidth: true
model: ["dataViz", "realistic"] model: ["dataViz", "realistic"]
Component.onCompleted: currentIndex = backend.renderMode === "realistic" ? 1 : 0 Component.onCompleted: currentIndex = Backend.renderMode === "realistic" ? 1 : 0
onActivated: backend.renderMode = currentText onActivated: Backend.renderMode = currentText
Connections { Connections {
target: backend target: Backend
function onRenderModeChanged() { function onRenderModeChanged() {
renderModeBox.currentIndex = backend.renderMode === "realistic" ? 1 : 0 renderModeBox.currentIndex = Backend.renderMode === "realistic" ? 1 : 0
} }
} }
} }
@@ -52,22 +64,28 @@ Pane {
RowLayout { RowLayout {
Layout.fillWidth: true 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 { ComboBox {
id: labelModeBox id: labelModeBox
Layout.fillWidth: true Layout.fillWidth: true
model: ["off", "hover", "always"] model: ["off", "hover", "always"]
Component.onCompleted: { Component.onCompleted: {
if (backend.labelMode === "always") labelModeBox.currentIndex = 2 if (Backend.labelMode === "always") labelModeBox.currentIndex = 2
else if (backend.labelMode === "hover") labelModeBox.currentIndex = 1 else if (Backend.labelMode === "hover") labelModeBox.currentIndex = 1
else labelModeBox.currentIndex = 0 else labelModeBox.currentIndex = 0
} }
onActivated: backend.labelMode = currentText onActivated: Backend.labelMode = currentText
Connections { Connections {
target: backend target: Backend
function onLabelModeChanged() { function onLabelModeChanged() {
if (backend.labelMode === "always") labelModeBox.currentIndex = 2 if (Backend.labelMode === "always") labelModeBox.currentIndex = 2
else if (backend.labelMode === "hover") labelModeBox.currentIndex = 1 else if (Backend.labelMode === "hover") labelModeBox.currentIndex = 1
else labelModeBox.currentIndex = 0 else labelModeBox.currentIndex = 0
} }
} }
@@ -76,15 +94,21 @@ Pane {
Toggle { Toggle {
id: legendToggle id: legendToggle
text: qsTr("Legend") text: Qt.binding(function() {
checked: backend.showLegend I18n.retranslateToken
onCheckedChanged: backend.showLegend = checked return qsTr("Legend")
})
checked: Backend.showLegend
onCheckedChanged: Backend.showLegend = checked
} }
} }
} }
GroupBox { GroupBox {
title: qsTr("Scale") title: Qt.binding(function() {
I18n.retranslateToken
return qsTr("Scale")
})
Layout.fillWidth: true Layout.fillWidth: true
ColumnLayout { ColumnLayout {
@@ -93,35 +117,47 @@ Pane {
RowLayout { RowLayout {
Layout.fillWidth: true 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 { SpinBox {
id: minBox id: minBox
Layout.fillWidth: true Layout.fillWidth: true
from: -999999 from: -999999
to: 999999 to: 999999
value: backend.minValue value: Backend.minValue
onValueModified: backend.minValue = value onValueModified: Backend.minValue = value
} }
} }
RowLayout { RowLayout {
Layout.fillWidth: true 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 { SpinBox {
id: maxBox id: maxBox
Layout.fillWidth: true Layout.fillWidth: true
from: -999999 from: -999999
to: 999999 to: 999999
value: backend.maxValue value: Backend.maxValue
onValueModified: backend.maxValue = value onValueModified: Backend.maxValue = value
} }
} }
Legend { Legend {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
visible: backend.showLegend visible: Backend.showLegend
minValue: backend.minValue minValue: Backend.minValue
maxValue: backend.maxValue maxValue: Backend.maxValue
} }
} }
} }

View File

@@ -1,8 +1,12 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import TactileIPC 1.0
Slider { Slider {
property string lableText: qsTr("Text") property string lableText: Qt.binding(function() {
I18n.retranslateToken
return qsTr("Text")
})
stepSize: 1 stepSize: 1
Label { Label {

421
qml/content/LeftPanel.qml Normal file
View File

@@ -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)
}
}
}

View File

@@ -1,6 +1,7 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import TactileIPC 1.0
Item { Item {
id: root id: root

View File

@@ -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
}
}
}
}
}
}

165
qml/content/NavBar.qml Normal file
View File

@@ -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
}
}
}
}

198
qml/content/RightPanel.qml Normal file
View File

@@ -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 }
}
}

View File

@@ -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()
}
}

View File

@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import TactileIPC 1.0
Item { Item {
property string text property string text

View File

@@ -1,11 +1,16 @@
<!DOCTYPE RCC>
<RCC> <RCC>
<qresource prefix="/"> <qresource prefix="/">
<file>qml/Main.qml</file> <file>qml/Main.qml</file>
<file>qml/content/App.qml</file> <file>qml/content/App.qml</file>
<file>qml/content/NavBar.qml</file>
<file>qml/content/ControlPanel.qml</file> <file>qml/content/ControlPanel.qml</file>
<file>qml/content/Legend.qml</file> <file>qml/content/Legend.qml</file>
<file>qml/content/LabeledSlider.qml</file> <file>qml/content/LabeledSlider.qml</file>
<file>qml/content/Toggle.qml</file> <file>qml/content/Toggle.qml</file>
<file>qml/content/LeftPanel.qml</file>
<file>qml/content/RightPanel.qml</file>
<file>qml/content/CollapsiblePanel.qml</file>
<file>shaders/dots.frag</file> <file>shaders/dots.frag</file>
<file>shaders/dots.vert</file> <file>shaders/dots.vert</file>
<file>shaders/bg.frag</file> <file>shaders/bg.frag</file>
@@ -13,5 +18,21 @@
<file>shaders/panel.frag</file> <file>shaders/panel.frag</file>
<file>shaders/panel.vert</file> <file>shaders/panel.vert</file>
<file>images/metal.jpeg</file> <file>images/metal.jpeg</file>
<file>shaders/room.frag</file>
<file>shaders/room.vert</file>
<file>qml/content/LiveTrendCard.qml</file>
<file>qml/content/SaveAsExportDialog.qml</file>
<file>images/computer_dark.png</file>
<file>images/computer_light.png</file>
<file>images/desktop_light.png</file>
<file>images/docs_dark.png</file>
<file>images/download_dark.png</file>
<file>images/download_light.png</file>
<file>images/folder_dark.png</file>
<file>images/folder_light.png</file>
<file>images/docs_light.png</file>
<file>images/desktop_dark.png</file>
<file>images/china.png</file>
<file>images/united-states.png</file>
</qresource> </qresource>
</RCC> </RCC>

View File

@@ -7,6 +7,7 @@ uniform vec2 uViewport;
// 以像素为单位的网格间距:细网格/粗网格 // 以像素为单位的网格间距:细网格/粗网格
uniform float uMinorStep; uniform float uMinorStep;
uniform float uMajorStep; uniform float uMajorStep;
uniform bool uLightMode;
// 生成抗锯齿网格线(返回 0..11 表示在线上) // 生成抗锯齿网格线(返回 0..11 表示在线上)
float gridLine(float stepPx) { float gridLine(float stepPx) {
@@ -23,20 +24,27 @@ void main() {
vec2 viewport = max(uViewport, vec2(1.0)); vec2 viewport = max(uViewport, vec2(1.0));
vec2 uv = gl_FragCoord.xy / viewport; // 0..1 vec2 uv = gl_FragCoord.xy / viewport; // 0..1
vec3 topCol, botCol, minorCol, majorCol;
float minorStrength, majorStrength;
float vignettePow, vignetteStrength;
// 背景渐变:上更亮、下稍灰,常见 3D 软件的“科技感”底色 // 背景渐变:上更亮、下稍灰,常见 3D 软件的“科技感”底色
vec3 topCol = vec3(0.99, 0.99, 1.00); topCol = vec3(0.99, 0.99, 1.00);
vec3 botCol = vec3(0.94, 0.95, 0.98); 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); vec3 col = mix(botCol, topCol, uv.y);
// 网格线:细线 + 粗线(每隔一段更深一点)
float minor = gridLine(max(uMinorStep, 1.0)); float minor = gridLine(max(uMinorStep, 1.0));
float major = gridLine(max(uMajorStep, 1.0)); float major = gridLine(max(uMajorStep, 1.0));
col = mix(col, minorCol, minor * minorStrength);
vec3 minorCol = vec3(0.80, 0.82, 0.87); col = mix(col, majorCol, major * majorStrength);
vec3 majorCol = vec3(0.70, 0.73, 0.80);
col = mix(col, minorCol, minor * 0.22);
col = mix(col, majorCol, major * 0.35);
// 轻微 vignette四角略暗让画面更“聚焦” // 轻微 vignette四角略暗让画面更“聚焦”
vec2 p = uv * 2.0 - 1.0; vec2 p = uv * 2.0 - 1.0;

View File

@@ -117,19 +117,31 @@ void main() {
float value01 = clamp((vValue - uMinV) / max(1e-6, (uMaxV - uMinV)), 0.0, 1.0); float value01 = clamp((vValue - uMinV) / max(1e-6, (uMaxV - uMinV)), 0.0, 1.0);
vec3 dataCol = dataColorRamp(value01); vec3 dataCol = dataColorRamp(value01);
bool hasData = (uHasData != 0); // bool hasData = (uHasData != 0);
vec3 baseColor = hasData ? dataCol : metalBase; // 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. // 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)); 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); vec3 V = normalize(uCameraPos - vWorldPos);
float metallic = hasData ? 0.0 : 0.90; // float metallic = hasData ? 0.0 : 0.90;
float roughness = hasData ? 0.78 : ((uRenderMode == 1) ? 0.70 : 0.55); // 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)); // "Front light": make the light come from the camera direction (like a headlight/flashlight).
vec3 fillL = normalize(vec3(-0.30, 0.70, -0.80)); vec3 keyL = V;
vec3 fillL = V;
vec3 keyC = vec3(1.00, 0.98, 0.95) * 1.8; vec3 keyC = vec3(1.00, 0.98, 0.95) * 1.8;
vec3 fillC = vec3(0.85, 0.90, 1.00) * 0.9; vec3 fillC = vec3(0.85, 0.90, 1.00) * 0.9;

View File

@@ -12,18 +12,18 @@ out vec3 vWorldPos;
uniform mat4 uMVP; // Projection * View * Model这里 Model 约等于单位矩阵) uniform mat4 uMVP; // Projection * View * Model这里 Model 约等于单位矩阵)
uniform float uDotRadius; // dot 半径(世界坐标单位) uniform float uDotRadius; // dot 半径(世界坐标单位)
uniform float uBaseY; // dot 的高度(通常 = panel 顶面 y + 一点点偏移) uniform float uBaseZ; // dot 的高度(通常 = panel 顶面 y + 一点点偏移)
void main() { void main() {
vUV = aUV; vUV = aUV;
vValue = iValue; vValue = iValue;
// 先确定 dot 的中心点(世界坐标) // 先确定 dot 的中心点(世界坐标)
vec3 world = vec3(iOffsetXZ.x, uBaseY, iOffsetXZ.y); vec3 world = vec3(iOffsetXZ.x, iOffsetXZ.y, uBaseZ);
// 再把单位 quad 按半径缩放并加到中心点上(让 quad 落在 XZ 平面) // 再把单位 quad 按半径缩放并加到中心点上(让 quad 落在 XZ 平面)
world.x += qQuadPos.x * uDotRadius; world.x += qQuadPos.x * uDotRadius;
world.z += qQuadPos.y * uDotRadius; world.y += qQuadPos.y * uDotRadius;
// 输出裁剪空间坐标(最终会进行透视除法与视口映射,变成屏幕上的像素) // 输出裁剪空间坐标(最终会进行透视除法与视口映射,变成屏幕上的像素)
vWorldPos = world; vWorldPos = world;

View File

@@ -12,6 +12,7 @@ uniform int uCols;
uniform float uPitch; uniform float uPitch;
uniform float uDotRadius; uniform float uDotRadius;
uniform int uRenderMode; // 0=realistic, 1=dataViz uniform int uRenderMode; // 0=realistic, 1=dataViz
uniform bool uLightMode;
const float PI = 3.14159265359; const float PI = 3.14159265359;
@@ -146,10 +147,31 @@ void main() {
// ------------------------------------------------------------ // ------------------------------------------------------------
// Industrial engineering model: neutral matte gray panel (support layer only) // Industrial engineering model: neutral matte gray panel (support layer only)
// ------------------------------------------------------------ // ------------------------------------------------------------
vec3 topBase = vec3(0.30, 0.31, 0.32); vec3 topBase, sideBase;
vec3 sideBase = vec3(0.27, 0.28, 0.29); 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); 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; vec2 xz = vWorldPos.xz;
float dotContact = 0.0; float dotContact = 0.0;
if (isTop > 0.5 && uDotRadius > 0.0) { if (isTop > 0.5 && uDotRadius > 0.0) {
@@ -158,9 +180,11 @@ void main() {
dotContact = 1.0 - smoothstep(uDotRadius, uDotRadius + w, d); 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 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 hw = max(1e-6, uPanelW * 0.5);
float hd = max(1e-6, uPanelD * 0.5); float hd = max(1e-6, uPanelD * 0.5);
@@ -172,8 +196,8 @@ void main() {
float ao = 1.0 - dotContact * 0.08; float ao = 1.0 - dotContact * 0.08;
vec3 col = baseColor * lighting * ao; vec3 col = baseColor * lighting * ao;
col += edgeLine * vec3(0.020); col += edgeLine * edgeCol;
col += rim * vec3(0.015); col += rim * rimCol;
// Slightly deepen the bottom face to read as thickness, but keep it subtle. // Slightly deepen the bottom face to read as thickness, but keep it subtle.
float isBottom = step(0.75, -N.y); float isBottom = step(0.75, -N.y);

108
shaders/room.frag Normal file
View File

@@ -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);
}

19
shaders/room.vert Normal file
View File

@@ -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);
}

View File

@@ -3,62 +3,48 @@
// //
#include "backend.h" #include "backend.h"
#include "data_backend.h"
#include "serial/serial_backend.h"
#include <qnumeric.h>
static QString normalizeRenderMode_(const QString& mode) { AppBackend::AppBackend(QObject* parent)
if (mode.compare(QStringLiteral("realistic"), Qt::CaseInsensitive) == 0) : QObject(parent)
return QStringLiteral("realistic"); , m_serial(new SerialBackend(this))
return QStringLiteral("dataViz"); , 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) { bool AppBackend::connected() const {
if (mode.compare(QStringLiteral("hover"), Qt::CaseInsensitive) == 0) return m_serial && m_serial->connected();
return QStringLiteral("hover");
if (mode.compare(QStringLiteral("always"), Qt::CaseInsensitive) == 0)
return QStringLiteral("always");
return QStringLiteral("off");
} }
Backend::Backend(QObject *parent) void AppBackend::setLanguage(const QString& lang) {
: QObject(parent) { if (m_language == lang)
}
void Backend::setMinValue(int v) {
if (m_min == v)
return; return;
m_min = v; m_language = lang;
emit minValueChanged(); emit languageChanged();
emit rangeChanged(m_min, m_max);
} }
void Backend::setMaxValue(int v) { void AppBackend::setLightMode(bool on) {
if (m_max == v) if (m_lightMode == on)
return; return;
m_max = v; m_lightMode = on;
emit maxValueChanged(); emit lightModeChanged();
emit rangeChanged(m_min, m_max);
} }
void Backend::setRenderMode(const QString &mode) { void AppBackend::setShowGrid(bool on) {
const QString norm = normalizeRenderMode_(mode); qInfo() << "setShowGrid:" << on;
if (m_renderMode == norm) if (m_showGrid == on)
return; return;
m_renderMode = norm;
emit renderModeChanged();
emit renderModeValueChanged(m_renderMode);
}
void Backend::setShowLegend(bool show) { m_showGrid = on;
if (m_showLegend == show) emit showGridChanged(on);
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);
} }

View File

@@ -4,51 +4,50 @@
#ifndef TACTILEIPC3D_BACKEND_H #ifndef TACTILEIPC3D_BACKEND_H
#define TACTILEIPC3D_BACKEND_H #define TACTILEIPC3D_BACKEND_H
#include <qobject.h> #include <QObject>
#include <QString> #include <QString>
#include <qtmetamacros.h>
#include "data_backend.h"
#include "serial/serial_backend.h"
class Backend : public QObject { class AppBackend : public QObject {
Q_OBJECT Q_OBJECT
Q_PROPERTY(int minValue READ minValue WRITE setMinValue NOTIFY minValueChanged) Q_PROPERTY(bool lightMode READ lightMode WRITE setLightMode NOTIFY lightModeChanged)
Q_PROPERTY(int maxValue READ maxValue WRITE setMaxValue NOTIFY maxValueChanged) Q_PROPERTY(QString language READ language WRITE setLanguage NOTIFY languageChanged)
Q_PROPERTY(QString renderMode READ renderMode WRITE setRenderMode NOTIFY renderModeChanged) Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged)
Q_PROPERTY(bool showLegend READ showLegend WRITE setShowLegend NOTIFY showLegendChanged) Q_PROPERTY(bool showGrid READ showGrid WRITE setShowGrid NOTIFY showGridChanged);
Q_PROPERTY(QString labelMode READ labelMode WRITE setLabelMode NOTIFY labelModeChanged) Q_PROPERTY(SerialBackend* serial READ serial CONSTANT)
Q_PROPERTY(DataBackend* data READ data CONSTANT)
public: public:
explicit Backend(QObject* parent = nullptr); explicit AppBackend(QObject* parent=nullptr);
int minValue() const { return m_min; } bool lightMode() const { return m_lightMode; }
int maxValue() const { return m_max; } QString language() const { return m_language; }
QString renderMode() const { return m_renderMode; } bool connected() const;
bool showLegend() const { return m_showLegend; }
QString labelMode() const { return m_labelMode; }
public slots: SerialBackend* serial() const { return m_serial; }
void setMinValue(int v); DataBackend* data() const { return m_data; }
void setMaxValue(int v);
void setRenderMode(const QString& mode); void setLanguage(const QString& lang);
void setShowLegend(bool show); void setLightMode(bool on);
void setLabelMode(const QString& mode);
bool showGrid() const { return m_showGrid; }
void setShowGrid(bool on);
signals: signals:
void minValueChanged(); void lightModeChanged();
void maxValueChanged(); void languageChanged();
void renderModeChanged(); void connectedChanged();
void showLegendChanged(); void showGridChanged(bool on);
void labelModeChanged();
void rangeChanged(int minV, int maxV);
void renderModeValueChanged(const QString& mode);
void labelModeValueChanged(const QString& mode);
private: private:
int m_min = 100; SerialBackend* m_serial = nullptr;
int m_max = 2000; DataBackend* m_data = nullptr;
QString m_renderMode = QStringLiteral("dataViz"); bool m_lightMode = true;
bool m_showLegend = true; QString m_language = QStringLiteral("zh_CN");
QString m_labelMode = QStringLiteral("off");
bool m_showGrid = true;
}; };
#endif //TACTILEIPC3D_BACKEND_H #endif //TACTILEIPC3D_BACKEND_H

338
src/data_backend.cpp Normal file
View File

@@ -0,0 +1,338 @@
#include "data_backend.h"
#include <QFile>
#include <QIODevice>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTextStream>
#include <QDir>
#include <cmath>
#include <qtpreprocessorsupport.h>
#include <qurl.h>
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<double>(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<quint8>(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<float>(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<double>(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<float>(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<double>(v) * static_cast<double>(v);
if (v < minV)
minV = v;
if (v > maxV)
maxV = v;
}
const double count = static_cast<double>(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();
}

83
src/data_backend.h Normal file
View File

@@ -0,0 +1,83 @@
#ifndef TACTILEIPC3D_DATA_BACKEND_H
#define TACTILEIPC3D_DATA_BACKEND_H
#include <QObject>
#include <QVector>
#include <QTimer>
#include <QString>
#include <functional>
#include <QSerialPort>
#include <QSerialPortInfo>
#include <qtmetamacros.h>
#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<void(const DataFrame&)>;
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<DataFrame> 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

19
src/data_frame.h Normal file
View File

@@ -0,0 +1,19 @@
#ifndef TACTILEIPC3D_DATA_FRAME_H
#define TACTILEIPC3D_DATA_FRAME_H
#include <QVector>
#include <QString>
#include <QDateTime>
#include <QtGlobal>
struct DataFrame {
QString pts;
quint8 functionCode = 0;
QVector<float> data;
static QString makePts(const QDateTime& dt) {
return dt.toString(QStringLiteral("yyyyMMddhhmmsszzz"));
}
};
#endif // TACTILEIPC3D_DATA_FRAME_H

View File

@@ -8,13 +8,21 @@
#include <QtMath> #include <QtMath>
#include <QFile> #include <QFile>
#include <QDebug> #include <QDebug>
#include <GL/gl.h>
#include <qevent.h> #include <qevent.h>
#include <qlogging.h>
#include <qminmax.h>
#include <qopenglext.h>
#include <qopenglshaderprogram.h>
#include <qstringliteral.h>
#include <qvectornd.h>
#include <QVector3D> #include <QVector3D>
#include <QVector2D> #include <QVector2D>
#include <QVector4D> #include <QVector4D>
#include <QImage> #include <QImage>
#include <QPainter> #include <QPainter>
#include <QFontMetrics> #include <QFontMetrics>
#include <algorithm>
#include <limits> #include <limits>
// 读取文本文件内容(这里主要用来从 Qt Resource `:/shaders/...` 读取 shader 源码) // 读取文本文件内容(这里主要用来从 Qt Resource `:/shaders/...` 读取 shader 源码)
@@ -77,7 +85,7 @@ void GLWidget::setPanelSize(float w, float h, float d) {
void GLWidget::setPanelThickness(float h) { void GLWidget::setPanelThickness(float h) {
if (qFuzzyCompare(m_panelH, h)) if (qFuzzyCompare(m_panelH, h))
return; return;
m_panelH = h; m_panelD = h;
m_panelGeometryDirty = true; m_panelGeometryDirty = true;
update(); update();
} }
@@ -91,9 +99,9 @@ void GLWidget::setSpec(int rows, int cols, float pitch, float dotRaius) {
// 自动根据点阵尺寸调整 panel 的尺寸(保证圆点不会越过顶面边界)。 // 自动根据点阵尺寸调整 panel 的尺寸(保证圆点不会越过顶面边界)。
// 约定:点中心覆盖的范围是 (cols-1)*pitch / (rows-1)*pitch面板需要额外留出 dotRadius 的边缘空间。 // 约定:点中心覆盖的范围是 (cols-1)*pitch / (rows-1)*pitch面板需要额外留出 dotRadius 的边缘空间。
const float gridW = float(qMax(0, m_cols - 1)) * m_pitch; 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_panelW = gridW + 2.0f * m_dotRadius;
m_panelD = gridD + 2.0f * m_dotRadius; m_panelH = gridH + 2.0f * m_dotRadius;
m_panelGeometryDirty = true; m_panelGeometryDirty = true;
m_dotsGeometryDirty = true; m_dotsGeometryDirty = true;
@@ -166,6 +174,23 @@ void GLWidget::setLabelModeString(const QString &mode) {
update(); 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() { void GLWidget::initializeGL() {
initializeOpenGLFunctions(); initializeOpenGLFunctions();
@@ -177,6 +202,7 @@ void GLWidget::initializeGL() {
initBackgroundGeometry_(); initBackgroundGeometry_();
initPanelGeometry_(); initPanelGeometry_();
initDotGeometry_(); initDotGeometry_();
initRoomGeometry_();
m_panelGeometryDirty = false; m_panelGeometryDirty = false;
m_dotsGeometryDirty = 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 float dpr = devicePixelRatioF();
const QVector2D viewport(float(width()) * dpr, float(height()) * dpr); const QVector2D viewport(float(width()) * dpr, float(height()) * dpr);
@@ -229,6 +255,7 @@ void GLWidget::paintGL() {
// 1) 更新相机/投影矩阵MVP决定如何把 3D 世界投影到屏幕 // 1) 更新相机/投影矩阵MVP决定如何把 3D 世界投影到屏幕
updateMatrices_(); updateMatrices_();
updateRoom_();
// 2) 如果外部提交了新数据,就把 CPU 生成的 instance 数据更新到 GPU // 2) 如果外部提交了新数据,就把 CPU 生成的 instance 数据更新到 GPU
updateInstanceBufferIfNeeded_(); updateInstanceBufferIfNeeded_();
@@ -245,7 +272,8 @@ void GLWidget::paintGL() {
m_panelProg->setUniformValue("uCols", m_cols); m_panelProg->setUniformValue("uCols", m_cols);
m_panelProg->setUniformValue("uPitch", m_pitch); m_panelProg->setUniformValue("uPitch", m_pitch);
m_panelProg->setUniformValue("uDotRadius", m_dotRadius); 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); glBindVertexArray(m_panelVao);
glDrawElements(GL_TRIANGLES, m_panelIndexCount, GL_UNSIGNED_INT, nullptr); glDrawElements(GL_TRIANGLES, m_panelIndexCount, GL_UNSIGNED_INT, nullptr);
glBindVertexArray(0); glBindVertexArray(0);
@@ -257,16 +285,16 @@ void GLWidget::paintGL() {
// uniforms每次 draw 前设置的一组“常量参数”(对当前 draw call 的所有 instance 都一致) // uniforms每次 draw 前设置的一组“常量参数”(对当前 draw call 的所有 instance 都一致)
// uMVP: 同上;用于把每个 dot 的世界坐标变换到屏幕 // uMVP: 同上;用于把每个 dot 的世界坐标变换到屏幕
m_dotsProg->setUniformValue("uMVP", m_mvp); m_dotsProg->setUniformValue("uMVP", m_mvp);
m_dotsProg->setUniformValue("uRenderMode", int(m_renderMode)); m_dotsProg->setUniformValue("uRenderMode", 1);
// uDotRadius: dot 的半径(世界坐标单位) // uDotRadius: dot 的半径(世界坐标单位)
m_dotsProg->setUniformValue("uDotRadius", m_dotRadius); m_dotsProg->setUniformValue("uDotRadius", m_dotRadius);
// uBaseY: dot 的高度(放在 panel 顶面上方一点点,避免 z-fighting/闪烁) // 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 映射成颜色 // uMinV/uMaxV: 传感值范围,用于 fragment shader 把 value 映射成颜色
m_dotsProg->setUniformValue("uMinV", float(m_min)); m_dotsProg->setUniformValue("uMinV", float(m_min));
m_dotsProg->setUniformValue("uMaxV", float(m_max)); m_dotsProg->setUniformValue("uMaxV", float(m_max));
const int hasData = (m_hasData.load() || m_dotTex == 0) ? 1 : 0; 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("uCameraPos", m_cameraPos);
m_dotsProg->setUniformValue("uDotTex", 0); m_dotsProg->setUniformValue("uDotTex", 0);
if (m_dotTex) { if (m_dotTex) {
@@ -282,7 +310,7 @@ void GLWidget::paintGL() {
glBindTexture(GL_TEXTURE_2D, 0); glBindTexture(GL_TEXTURE_2D, 0);
} }
m_dotsProg->release(); // m_dotsProg->release();
} }
if (m_labelMode != LabelsOff && dotCount() > 0) { if (m_labelMode != LabelsOff && dotCount() > 0) {
@@ -396,6 +424,25 @@ void GLWidget::mousePressEvent(QMouseEvent *event) {
event->accept(); event->accept();
return; 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); QOpenGLWidget::mousePressEvent(event);
} }
@@ -483,11 +530,75 @@ void GLWidget::mouseReleaseEvent(QMouseEvent *event) {
void GLWidget::wheelEvent(QWheelEvent *event) { void GLWidget::wheelEvent(QWheelEvent *event) {
const float steps = event->angleDelta().y() / 120.0f; const float steps = event->angleDelta().y() / 120.0f;
const float factor = std::pow(0.9f, steps); 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(); update();
event->accept(); 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<float>::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, &center))
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_() { void GLWidget::initBackgroundGeometry_() {
if (m_bgVbo) { if (m_bgVbo) {
glDeleteBuffers(1, &m_bgVbo); glDeleteBuffers(1, &m_bgVbo);
@@ -675,7 +786,6 @@ void GLWidget::initDotGeometry_() {
glBindVertexArray(0); glBindVertexArray(0);
} }
void GLWidget::initDotTexture_() { void GLWidget::initDotTexture_() {
if (m_dotTex) { if (m_dotTex) {
glDeleteTextures(1, &m_dotTex); glDeleteTextures(1, &m_dotTex);
@@ -712,6 +822,91 @@ void GLWidget::initDotTexture_() {
glBindTexture(GL_TEXTURE_2D, 0); 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_() { void GLWidget::initPrograms_() {
// Qt Resource 里打包的 shader 文件路径:使用 `:/` 前缀(不是文件系统路径) // Qt Resource 里打包的 shader 文件路径:使用 `:/` 前缀(不是文件系统路径)
const QString vsd_path = QStringLiteral(":/shaders/dots.vert"); 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 fsb_path = QStringLiteral(":/shaders/bg.frag");
const QString vsp_path = QStringLiteral(":/shaders/panel.vert"); const QString vsp_path = QStringLiteral(":/shaders/panel.vert");
const QString fsp_path = QStringLiteral(":/shaders/panel.frag"); 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; auto *p = new QOpenGLShaderProgram;
@@ -757,6 +954,18 @@ void GLWidget::initPrograms_() {
} }
m_dotsProg = p; 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_() { void GLWidget::updateInstanceBufferIfNeeded_() {
@@ -825,7 +1034,7 @@ void GLWidget::updateMatrices_() {
const float distance = qMax(0.5f, radius * 2.5f); const float distance = qMax(0.5f, radius * 2.5f);
// 让相机看向 panel 的中心点 // 让相机看向 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 // yaw/pitch 控制相机绕目标点“环绕”orbit camera
const float yawRad = qDegreesToRadians(m_camYawDeg); const float yawRad = qDegreesToRadians(m_camYawDeg);
@@ -849,3 +1058,43 @@ void GLWidget::updateMatrices_() {
m_mvp = proj * view; 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();
}

View File

@@ -1,10 +1,11 @@
//
// Created by Lenn on 2025/12/16. // Created by Lenn on 2025/12/16.
// //
#ifndef TACTILEIPC3D_GLWIDGET_H #ifndef TACTILEIPC3D_GLWIDGET_H
#define TACTILEIPC3D_GLWIDGET_H #define TACTILEIPC3D_GLWIDGET_H
#include <qopenglshaderprogram.h>
#include <QOpenGLWidget> #include <QOpenGLWidget>
#include <QOpenGLFunctions> #include <QOpenGLFunctions>
#include <QMutex> #include <QMutex>
@@ -14,10 +15,18 @@
#include <QVector3D> #include <QVector3D>
#include <QString> #include <QString>
#include <atomic> #include <atomic>
#include <qtmetamacros.h>
#include <qvectornd.h>
struct Ray {
QVector3D origin;
QVector3D dir;
};
class GLWidget : public QOpenGLWidget, protected QOpenGLFunctions_3_3_Core { class GLWidget : public QOpenGLWidget, protected QOpenGLFunctions_3_3_Core {
Q_OBJECT Q_OBJECT
Q_PROPERTY(float yaw READ yaw WRITE setYaw NOTIFY yawChanged) Q_PROPERTY(float yaw READ yaw WRITE setYaw NOTIFY yawChanged)
// Q_PROPERTY(bool showGrid READ showGrid WRITE setShowGrid NOTIFY showGridChanged)
public: public:
enum RenderMode { enum RenderMode {
Realistic = 0, Realistic = 0,
@@ -48,15 +57,23 @@ public:
float yaw() const { return m_camYawDeg; } float yaw() const { return m_camYawDeg; }
bool showGrid() const { return m_showGrid; }
public slots: public slots:
// 值域范围,用于 shader 里把 value 映射到颜色(绿->红) // 值域范围,用于 shader 里把 value 映射到颜色(绿->红)
void setRange(int minV, int maxV); void setRange(int minV, int maxV);
void setYaw(float yawDeg); void setYaw(float yawDeg);
void setRenderModeString(const QString& mode); void setRenderModeString(const QString& mode);
void setLabelModeString(const QString& mode); void setLabelModeString(const QString& mode);
void setLightMode(bool on);
void setShowBg(bool on);
void setShowGrid(bool on);
signals: signals:
void yawChanged(); void yawChanged();
void dotClicked(int index, int row, int col, float value);
protected: protected:
void initializeGL() override; void initializeGL() override;
@@ -73,9 +90,12 @@ private:
void initBackgroundGeometry_(); void initBackgroundGeometry_();
void initPrograms_(); void initPrograms_();
void initDotTexture_(); void initDotTexture_();
void initRoomGeometry_();
void updateInstanceBufferIfNeeded_(); void updateInstanceBufferIfNeeded_();
void updateMatrices_(); void updateMatrices_();
void updateRoom_();
bool projectToScreen_(const QVector3D& world, QPointF* out) const;
int pickDotIndex_(const QPoint& pos, QVector3D* worldOut) const;
private: private:
// 传感值范围(用于颜色映射) // 传感值范围(用于颜色映射)
int m_min = 0; int m_min = 0;
@@ -86,9 +106,9 @@ private:
int m_cols = 4; int m_cols = 4;
// panel: 一个长方体/板子(当前只画顶面矩形) // panel: 一个长方体/板子(当前只画顶面矩形)
float m_panelW = 1.2f; float m_panelW = 0.25f;
float m_panelH = 0.08f; float m_panelH = 0.35f;
float m_panelD = 0.08f; float m_panelD = 0.05f;
// 点阵布局参数 // 点阵布局参数
float m_pitch = 0.1f; float m_pitch = 0.1f;
@@ -103,6 +123,7 @@ private:
// shader program编译/链接后的可执行 GPU 程序) // shader program编译/链接后的可执行 GPU 程序)
QOpenGLShaderProgram* m_bgProg = nullptr; QOpenGLShaderProgram* m_bgProg = nullptr;
QOpenGLShaderProgram* m_roomProg = nullptr;
QOpenGLShaderProgram* m_panelProg = nullptr; QOpenGLShaderProgram* m_panelProg = nullptr;
QOpenGLShaderProgram* m_dotsProg = nullptr; QOpenGLShaderProgram* m_dotsProg = nullptr;
@@ -119,11 +140,20 @@ private:
unsigned int m_dotsVao = 0; unsigned int m_dotsVao = 0;
unsigned int m_dotsVbo = 0; unsigned int m_dotsVbo = 0;
unsigned int m_instanceVbo = 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; int m_instanceCount = 0;
bool m_dotsGeometryDirty = false; bool m_dotsGeometryDirty = false;
unsigned int m_bgVao = 0; unsigned int m_bgVao = 0;
unsigned int m_bgVbo = 0; unsigned int m_bgVbo = 0;
bool m_lightMode = true;
bool m_showBg = true;
// MVP = Projection * View * Model。 // MVP = Projection * View * Model。
// 这里 panel/dots 顶点基本已经是“世界坐标”,所以我们用 proj*view 即可model 先省略)。 // 这里 panel/dots 顶点基本已经是“世界坐标”,所以我们用 proj*view 即可model 先省略)。
@@ -140,11 +170,12 @@ private:
float m_modelPanel[16]{}; float m_modelPanel[16]{};
float m_zoom_ = 45.0; float m_zoom_ = 45.0;
float m_camYawDeg = 45.0f; float m_camYawDeg = -90.0f;
float m_camPitchDeg = 35.0f; float m_camPitchDeg = 0.0f;
std::atomic<bool> m_rightDown{false}; std::atomic<bool> m_rightDown{false};
QPoint m_lastPos; QPoint m_lastPos;
}; };

53
src/nice_ticks.h Normal file
View File

@@ -0,0 +1,53 @@
#ifndef NICE_TICKS
#define NICE_TICKS
#include <cmath>
#include <vector>
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<double> 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

0
src/ringbuffer.cpp Normal file
View File

82
src/ringbuffer.h Normal file
View File

@@ -0,0 +1,82 @@
#ifndef RINGBUFFER_H
#define RINGBUFFER_H
#include <cstddef>
#include <iostream>
#include <vector>
#include <atomic>
#include <algorithm>
#include <cstdint>
#include <type_traits>
template<typename T>
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<uint64_t>(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<T>& 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<T> m_data;
std::atomic<uint64_t> m_write{0};
std::atomic<uint64_t> m_size{0};
};
#endif

View File

@@ -0,0 +1,241 @@
#include "piezoresistive_a_protocol.h"
#include <QDateTime>
#include <qcontainerfwd.h>
#include <qtypes.h>
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<quint8>(data[offset]);
const quint8 b1 = static_cast<quint8>(data[offset + 1]);
return static_cast<quint16>(b0 | (b1 << 8));
}
quint32 readLe32(const QByteArray& data, int offset) {
const quint8 b0 = static_cast<quint8>(data[offset]);
const quint8 b1 = static_cast<quint8>(data[offset + 1]);
const quint8 b2 = static_cast<quint8>(data[offset + 2]);
const quint8 b3 = static_cast<quint8>(data[offset + 3]);
return static_cast<quint32>(b0 | (b1 << 8) | (b2 << 16) | (b3 << 24));
}
void appendLe16(QByteArray& data, quint16 v) {
data.append(static_cast<char>(v & 0xFF));
data.append(static_cast<char>((v >> 8) & 0xFF));
}
void appendLe32(QByteArray& data, quint32 v) {
data.append(static_cast<char>(v & 0xFF));
data.append(static_cast<char>((v >> 8) & 0xFF));
data.append(static_cast<char>((v >> 16) & 0xFF));
data.append(static_cast<char>((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<quint8>(data[i]);
for (int bit = 0; bit < 8; ++bit) {
if (crc & 0x80)
crc = static_cast<quint8>((crc << 1) ^ 0x07);
else
crc = static_cast<quint8>(crc << 1);
}
}
return static_cast<quint8>(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<quint8>((*buffer)[i]) == kReplyStart0 &&
static_cast<quint8>((*buffer)[i + 1]) == kReplyStart1) {
startIndex = i;
break;
}
if (static_cast<quint8>((*buffer)[i]) == kReplyStartAlt0 &&
static_cast<quint8>((*buffer)[i + 1]) == kReplyStartAlt1) {
startIndex = i;
break;
}
}
if (startIndex < 0) {
const quint8 tail = static_cast<quint8>(buffer->back());
buffer->clear();
if (tail == kReplyStart0 || tail == kReplyStart1)
buffer->append(static_cast<char>(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<quint8>(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<char>(kRequestStart0));
packet.append(static_cast<char>(kRequestStart1));
const quint16 payloadLen = 9;
appendLe16(packet, payloadLen);
packet.append(static_cast<char>(config.deviceAddress));
packet.append(static_cast<char>(0x00));
packet.append(static_cast<char>(0x80 | request.functionCode));
appendLe32(packet, request.startAddress);
appendLe16(packet, request.dataLength);
const quint8 crc = PiezoresistiveAFormat::crc8ITU(packet, packet.size());
packet.append(static_cast<char>(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<quint8>(packet[0]);
const quint8 start1 = static_cast<quint8>(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<quint8>(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<quint8>(packet[6]);
const quint16 dataLen = readLe16(packet, 11);
const quint8 status = static_cast<quint8>(packet[13]);
if (payloadLen != static_cast<quint16>(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<float> 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<float>(raw));
}
frame->pts = DataFrame::makePts(QDateTime::currentDateTime());
frame->functionCode = (funcRaw >= 0x80) ? static_cast<quint8>(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;
}

View File

@@ -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

View File

@@ -0,0 +1,358 @@
#include "serial_backend.h"
#include "piezoresistive_a_protocol.h"
#include "serial_qt_transport.h"
#include <QDebug>
#include <QMetaObject>
#include <QtGlobal>
#include <qcontainerfwd.h>
#include <qserialportinfo.h>
#include <vector>
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<PiezoresistiveACodec>();
auto decoder = std::make_shared<PiezoresistiveADecoder>();
auto format = std::make_shared<PiezoresistiveAFormat>();
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<QtSerialTransport>());
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<quint8>(capped))
return;
m_config.deviceAddress = static_cast<quint8>(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<quint8>(capped))
return;
m_request.functionCode = static_cast<quint8>(capped);
syncSendRequest_();
emit requestFunctionChanged();
}
void SerialBackend::setRequestStartAddress(int addr) {
const quint32 capped = static_cast<quint32>(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<quint16>(capped))
return;
m_request.dataLength = static_cast<quint16>(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<ISerialTransport> 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);
}

114
src/serial/serial_backend.h Normal file
View File

@@ -0,0 +1,114 @@
#ifndef TACTILEIPC3D_SERIAL_BACKEND_H
#define TACTILEIPC3D_SERIAL_BACKEND_H
#include <QObject>
#include <QStringList>
#include <QString>
#include <functional>
#include <memory>
#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<void(const DataFrame&)>;
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<int>(m_config.deviceAddress); }
QString mode() const;
int requestFunction() const { return m_request.functionCode; }
int requestStartAddress() const { return static_cast<int>(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<ISerialTransport> 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

23
src/serial/serial_codec.h Normal file
View File

@@ -0,0 +1,23 @@
#ifndef TACTILEIPC3D_SERIAL_CODEC_H
#define TACTILEIPC3D_SERIAL_CODEC_H
#include <QByteArray>
#include <QString>
#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

View File

@@ -0,0 +1,22 @@
#ifndef TACTILEIPC3D_SERIAL_DECODER_H
#define TACTILEIPC3D_SERIAL_DECODER_H
#include <QByteArray>
#include <QString>
#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

View File

@@ -0,0 +1,19 @@
#ifndef TACTILEIPC3D_SERIAL_FORMAT_H
#define TACTILEIPC3D_SERIAL_FORMAT_H
#include <QByteArray>
#include <QString>
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

View File

@@ -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);
}

View File

@@ -0,0 +1,31 @@
#ifndef TACTILEIPC3D_SERIAL_MANAGER_H
#define TACTILEIPC3D_SERIAL_MANAGER_H
#include <QHash>
#include <QString>
#include <memory>
#include "serial_codec.h"
#include "serial_decoder.h"
#include "serial_format.h"
class SerialManager {
public:
struct ProtocolBundle {
std::shared_ptr<ISerialCodec> codec;
std::shared_ptr<ISerialDecoder> decoder;
std::shared_ptr<ISerialFormat> 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<QString, ProtocolBundle> m_protocols;
QString m_activeName;
};
#endif // TACTILEIPC3D_SERIAL_MANAGER_H

View File

@@ -0,0 +1,140 @@
#include "serial_qt_transport.h"
#include "serial/serial_types.h"
#include <QSerialPort>
#include <QtGlobal>
#include <qcontainerfwd.h>
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;
}

View File

@@ -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

97
src/serial/serial_queue.h Normal file
View File

@@ -0,0 +1,97 @@
#ifndef TACTILEIPC3D_SERIAL_QUEUE_H
#define TACTILEIPC3D_SERIAL_QUEUE_H
#include <QQueue>
#include <QMutex>
#include <QWaitCondition>
#include <QByteArray>
#include <QtGlobal>
#include "../data_frame.h"
template <typename T>
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<T> m_queue;
int m_maxSize = 0;
bool m_stopped = false;
};
using PacketQueue = SerialQueue<QByteArray>;
using FrameQueue = SerialQueue<DataFrame>;
#endif // TACTILEIPC3D_SERIAL_QUEUE_H

View File

@@ -0,0 +1,216 @@
#include "serial_threads.h"
#include <QDebug>
#include <QtGlobal>
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<ISerialTransport> 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);
}

112
src/serial/serial_threads.h Normal file
View File

@@ -0,0 +1,112 @@
#ifndef TACTILEIPC3D_SERIAL_THREADS_H
#define TACTILEIPC3D_SERIAL_THREADS_H
#include <QThread>
#include <QMutex>
#include <QWaitCondition>
#include <QQueue>
#include <QTimer>
#include <QByteArray>
#include <QString>
#include <atomic>
#include <functional>
#include <memory>
#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<ISerialFormat::ParseResult(QByteArray*, QByteArray*, QString*)>;
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<QByteArray> 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<bool(const QByteArray&, DataFrame*, QString*)>;
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<QByteArray(const SerialConfig&, const SensorRequest&)>;
explicit SerialSendWorker(QObject* parent = nullptr);
void setTransport(std::unique_ptr<ISerialTransport> 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<ISerialTransport> m_transport;
QTimer m_pollTimer;
bool m_connected = false;
};
#endif // TACTILEIPC3D_SERIAL_THREADS_H

View File

@@ -0,0 +1,45 @@
#ifndef TACTILEIPC3D_SERIAL_TRANSPORT_H
#define TACTILEIPC3D_SERIAL_TRANSPORT_H
#include <QObject>
#include <QByteArray>
#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

43
src/serial/serial_types.h Normal file
View File

@@ -0,0 +1,43 @@
#ifndef TACTILEIPC3D_SERIAL_TYPES_H
#define TACTILEIPC3D_SERIAL_TYPES_H
#include <QString>
#include <QtGlobal>
#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

114
src/sparkline_plotitem.h Normal file
View File

@@ -0,0 +1,114 @@
#pragma once
#include <QQuickItem>
#include <QColor>
#include <algorithm>
#include <memory>
#include <qcolor.h>
#include <qcontainerfwd.h>
#include <qcoreapplication.h>
#include <qquickitem.h>
#include <qquickwindow.h>
#include <qsgnode.h>
#include <qsize.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include <unordered_map>
#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<RingBuffer<double>> 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<QString, TexEntry> m_textCache;
};

392
src/sparkling_plotitem.cpp Normal file
View File

@@ -0,0 +1,392 @@
#include "ringbuffer.h"
#include "sparkline_plotitem.h"
#include <QSGGeometryNode>
#include <QSGFlatColorMaterial>
#include <QSGSimpleTextureNode>
#include <QSGTexture>
#include <QPainter>
#include <QImage>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <iostream>
#include <memory>
#include <qcolor.h>
#include <qcontainerfwd.h>
#include <qfontmetrics.h>
#include <qimage.h>
#include <qnamespace.h>
#include <qpoint.h>
#include <qquickitem.h>
#include <qsggeometry.h>
#include <qsgnode.h>
#include <qsgtexture.h>
#include <qsize.h>
#include <qtypes.h>
#include <qvectornd.h>
#include <vector>
SparklinePlotItem::SparklinePlotItem(QQuickItem* parent)
: QQuickItem(parent) {
setFlag(ItemHasContents, true);
m_buf = std::make_unique<RingBuffer<double>>(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<RingBuffer<double>>(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<double>& buf, uint64_t startGidx, uint64_t count, int widthPx,
std::vector<QPointF>& 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<QSGGeometryNode*>(root->childAtIndex(0));
lineNode = static_cast<QSGGeometryNode*>(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<QSGFlatColorMaterial*>(gridNode->material())->setColor(m_gridColor);
static_cast<QSGFlatColorMaterial*>(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<uint64_t>(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<uint64_t>(oldest, startG);
// 降采样
std::vector<QPointF> 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;
}

View File

@@ -0,0 +1,45 @@
#include "translation_manager.h"
#include <QCoreApplication>
#include <QDebug>
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<QTranslator>();
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;
}

31
src/translation_manager.h Normal file
View File

@@ -0,0 +1,31 @@
#ifndef TRANSLATION_MANAGER_H
#define TRANSLATION_MANAGER_H
#include <QObject>
#include <QTranslator>
#include <QString>
#include <memory>
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<QTranslator> m_translator;
QString m_language;
int m_retranslateToken = 0;
};
#endif

View File

@@ -1,6 +1,8 @@
cmake_minimum_required(VERSION 3.5) cmake_minimum_required(VERSION 3.5)
project(base-project) project(base-project)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(X11 REQUIRED) find_package(X11 REQUIRED)
add_executable( add_executable(
${PROJECT_NAME} ${PROJECT_NAME}

28
test/onlygl/README.md Normal file
View File

@@ -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`

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,43 +1,122 @@
#include <cstddef> // GLFW 版本的渲染入口:画触觉面板盒子 + 点阵实例化圆点,
// 右键拖拽环绕相机,滚轮调 FOV内置简单波纹数据做演示。
#include <algorithm>
#include <cmath>
#include <iostream>
#include <vector>
#include <glad/glad.h> #include <glad/glad.h>
#include <GLFW/glfw3.h> #include <GLFW/glfw3.h>
#include <glm/ext/vector_float3.hpp> #include <glm/glm.hpp>
#include <iostream> #include <glm/gtc/matrix_transform.hpp>
#include "camera.h"
#include "myshader.hh" #include "myshader.hh"
#include "stb_image_wrap.h"
float deltaTime = 0.0f; struct AppState {
float lastFrame = 0.0f; // 点阵规格
float yaw = -90.0f; int rows = 8;
float pitch = 0.0f; int cols = 10;
float fov = 45.0f; float pitch = 0.025f;
Camera camera(glm::vec3(0.0f, 0.0f, 3.0)); 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 camYaw = -90.0f;
float panel_width = 1.2f; float camPitch = 0.0f;
float panel_height = 0.08f; float zoom = 45.0f;
float panel_deep = 0.08f; bool rightDown = false;
double lastMouseX = 0.0;
double lastMouseY = 0.0;
Shader bg_shader(); bool valuesDirty = true;
Shader panel_shader(); std::vector<float> values;
Shader dots_shader();
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_vao = 0;
unsigned int panel_vbo = 0; unsigned int panel_vbo = 0;
unsigned int panel_ibo = 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_vao = 0;
unsigned int dots_vbo = 0; unsigned int dots_vbo = 0;
unsigned int instance_vbo = 0; unsigned int instance_vbo = 0;
int instance_count = 0;
unsigned int dot_tex = 0;
unsigned int bg_vao = 0; unsigned int bg_vao = 0;
unsigned int bg_vbo = 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); 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<float>(xpos - g_state.lastMouseX);
const float dy = static_cast<float>(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<float>(yoffset));
g_state.zoom = std::clamp(g_state.zoom * factor, 5.0f, 80.0f);
}
void process_input(GLFWwindow* window) { void process_input(GLFWwindow* window) {
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) { if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) {
glfwSetWindowShouldClose(window, true); glfwSetWindowShouldClose(window, true);
@@ -45,58 +124,198 @@ void process_input(GLFWwindow* window) {
} }
GLFWwindow* glfw_init() { 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_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#if __APPLE__ #if __APPLE__
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif #endif
GLFWwindow* window = glfwCreateWindow(800, 600, "Tactile Module Test", NULL, NULL); GLFWwindow* window = glfwCreateWindow(800, 600, "Tactile Module Test (GLFW)", nullptr, nullptr);
if (window == NULL) { if (window == nullptr) {
std::cout << "Failed to create GLFW window" << std::endl; std::cerr << "Failed to create GLFW window" << std::endl;
glfwTerminate(); glfwTerminate();
return NULL; return nullptr;
} }
glfwMakeContextCurrent(window); glfwMakeContextCurrent(window);
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) { if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
std::cout << "Failed to initialize GLAD" << std::endl; std::cerr << "Failed to initialize GLAD" << std::endl;
return NULL; glfwDestroyWindow(window);
glfwTerminate();
return nullptr;
} }
glViewport(0, 0, 800, 600);
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback); 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; return window;
} }
void glfw_window_loop(GLFWwindow* window) { int dot_count() {
while (!glfwWindowShouldClose(window)) { // 当前点阵总数
process_input(window); return g_state.rows * g_state.cols;
glfwSwapBuffers(window);
glfwPollEvents();
}
} }
void destroy_context() { void set_panel_size(float w, float h, float d) {
if (panel_vao) { g_state.panelW = w;
glDeleteVertexArrays(1, &panel_vao); 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<float>(std::max(0, g_state.cols - 1)) * g_state.pitch;
const float gridD = static_cast<float>(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<float>(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) { if (bg_vao) {
glDeleteBuffers(1, &panel_vbo); 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) { if (room_vbo) {
glDeleteVertexArrays(1, &dots_vao); glDeleteBuffers(1, &room_vbo);
room_vbo = 0;
} }
if (dots_vbo) { if (room_vao) {
glDeleteBuffers(1, &dots_vbo); 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() { void init_panel_geometry() {
// 面板盒子VAO+VBO+IBO包含顶面/底面/侧面 6 个面
if (panel_ibo) { if (panel_ibo) {
glDeleteBuffers(1, &panel_ibo); glDeleteBuffers(1, &panel_ibo);
panel_ibo = 0; panel_ibo = 0;
@@ -110,65 +329,59 @@ void init_panel_geometry() {
panel_vao = 0; panel_vao = 0;
} }
const float y = panel_height * 0.5f; const float y = g_state.panelH * 0.5f;
const float hw = panel_width * 0.5f; const float hw = g_state.panelW * 0.5f;
const float hd = panel_deep * 0.5; const float hd = g_state.panelD * 0.5f;
using V = struct {
struct V {
float x, y, z; float x, y, z;
float nx, ny, nz; float nx, ny, nz;
}; };
V verts[24] = { V verts[24] = {
// +Y 顶面 (normal 0, +1, 0) {-hw, +y, -hd, 0, +1, 0},
{-hw, +y, -hd, 0, +1, 0}, // 0 {+hw, +y, -hd, 0, +1, 0},
{+hw, +y, -hd, 0, +1, 0}, // 1 {+hw, +y, +hd, 0, +1, 0},
{+hw, +y, +hd, 0, +1, 0}, // 2 {-hw, +y, +hd, 0, +1, 0},
{-hw, +y, +hd, 0, +1, 0}, // 3 {-hw, +y, +hd, 0, 0, +1},
{+hw, +y, +hd, 0, 0, +1},
// +Z 前面 (normal 0, 0, +1) {+hw, -y, +hd, 0, 0, +1},
{-hw, +y, +hd, 0, 0, +1}, // 4 {-hw, -y, +hd, 0, 0, +1},
{+hw, +y, +hd, 0, 0, +1}, // 5 {-hw, -y, +hd, 0, -1, 0},
{+hw, -y, +hd, 0, 0, +1}, // 6 {+hw, -y, +hd, 0, -1, 0},
{-hw, -y, +hd, 0, 0, +1}, // 7 {+hw, -y, -hd, 0, -1, 0},
{-hw, -y, -hd, 0, -1, 0},
// -Y 底面 (normal 0, -1, 0) {+hw, +y, -hd, 0, 0, -1},
{-hw, -y, +hd, 0, -1, 0}, // 8 {-hw, +y, -hd, 0, 0, -1},
{+hw, -y, +hd, 0, -1, 0}, // 9 {-hw, -y, -hd, 0, 0, -1},
{+hw, -y, -hd, 0, -1, 0}, // 10 {+hw, -y, -hd, 0, 0, -1},
{-hw, -y, -hd, 0, -1, 0}, // 11 {-hw, +y, -hd, -1, 0, 0},
{-hw, +y, +hd, -1, 0, 0},
// -Z 后面 (normal 0, 0, -1) {-hw, -y, +hd, -1, 0, 0},
{+hw, +y, -hd, 0, 0, -1}, // 12 {-hw, -y, -hd, -1, 0, 0},
{-hw, +y, -hd, 0, 0, -1}, // 13 {+hw, +y, +hd, +1, 0, 0},
{-hw, -y, -hd, 0, 0, -1}, // 14 {+hw, +y, -hd, +1, 0, 0},
{+hw, -y, -hd, 0, 0, -1}, // 15 {+hw, -y, -hd, +1, 0, 0},
{+hw, -y, +hd, +1, 0, 0},
// -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
}; };
unsigned int idx[36] = { unsigned int idx[36] = {
0, 1, 2, 0, 2, 3, // top 0, 1, 2, 0, 2, 3,
4, 5, 6, 4, 6, 7, // front 4, 5, 6, 4, 6, 7,
8, 9, 10, 8, 10,11, // bottom 8, 9, 10, 8, 10, 11,
12,13,14, 12,14,15, // back 12, 13, 14, 12, 14, 15,
16,17,18, 16,18,19, // left 16, 17, 18, 16, 18, 19,
20,21,22, 20,22,23 // right 20, 21, 22, 20, 22, 23
}; };
int panel_index_count = 36; panel_index_count = 36;
glGenVertexArrays(1, &panel_vao); glGenVertexArrays(1, &panel_vao);
glBindVertexArray(panel_vao); glBindVertexArray(panel_vao);
glGenBuffers(1, &panel_vbo); glGenBuffers(1, &panel_vbo);
glBindBuffer(GL_ARRAY_BUFFER, panel_vbo); glBindBuffer(GL_ARRAY_BUFFER, panel_vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW); glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW);
glGenBuffers(1, &panel_ibo); glGenBuffers(1, &panel_ibo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, panel_ibo); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, panel_ibo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(idx), idx, GL_STATIC_DRAW); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(idx), idx, GL_STATIC_DRAW);
@@ -176,28 +389,386 @@ void init_panel_geometry() {
glEnableVertexAttribArray(0); glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(V), (void*)0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(V), (void*)0);
glEnableVertexAttribArray(1); 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); glBindVertexArray(0);
} }
void set_panel_size(float w, float h, float d) { void init_dot_geometry() {
panel_width = w; // 圆点:基于一个单位 quad使用 instanced attributes 传位置/值
panel_height = h; if (instance_vbo) {
panel_deep = d; 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<float> inst;
inst.resize(n * 3);
const float w = static_cast<float>(std::max(0, g_state.cols - 1)) * g_state.pitch;
const float h = static_cast<float>(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<float>(c) * g_state.pitch) - w * 0.5f;
const float y = (static_cast<float>(r) * g_state.pitch) - h * 0.5f;
inst[i * 3 + 0] = x;
inst[i * 3 + 1] = y;
inst[i * 3 + 2] = (i < static_cast<int>(g_state.values.size())) ? g_state.values[i] : static_cast<float>(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<float>(fbWidth) / static_cast<float>(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<float>(fbWidth), static_cast<float>(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<float>(g_state.minV));
dots_shader->setFloat("uMaxV", static_cast<float>(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<int>(g_state.values.size()) != n) {
g_state.values.assign(n, static_cast<float>(g_state.minV));
}
for (int i = 0; i < n; ++i) {
const float phase = t * 0.6f + static_cast<float>(i) * 0.35f;
const float wave = 0.5f + 0.5f * std::sin(phase);
const float minV = static_cast<float>(g_state.minV);
const float maxV = static_cast<float>(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() { int main() {
// 初始化窗口/上下文
GLFWwindow* window = glfw_init(); GLFWwindow* window = glfw_init();
if (window == nullptr) {
if (window == NULL) {
return -1; 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<float>(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(); glfwTerminate();
return 0; return 0;
} }

View File

@@ -6,6 +6,8 @@
#include <fstream> #include <fstream>
#include <sstream> #include <sstream>
#include <iostream> #include <iostream>
#include <glm/gtc/type_ptr.hpp>
#include <glm/glm.hpp>
class Shader { class Shader {
public: public:
@@ -13,6 +15,7 @@ public:
unsigned int ID; unsigned int ID;
// 构造器读取并构建着色器 // 构造器读取并构建着色器
Shader(const char* vertexPath, const char* fragmentPath) { Shader(const char* vertexPath, const char* fragmentPath) {
std::cout << "begin compile [" << vertexPath << "] and [" << fragmentPath << "] !" << std::endl;
std::string vertexCode; std::string vertexCode;
std::string fragmentCode; std::string fragmentCode;
std::ifstream vShaderFile; std::ifstream vShaderFile;
@@ -70,13 +73,18 @@ public:
glLinkProgram(ID); glLinkProgram(ID);
glGetProgramiv(ID, GL_LINK_STATUS, &success); glGetProgramiv(ID, GL_LINK_STATUS, &success);
if (!success) { if (!success) {
glGetShaderInfoLog(ID, 512, NULL, infoLog); glGetProgramInfoLog(ID, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl; std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
} }
glDeleteShader(vertex); glDeleteShader(vertex);
glDeleteShader(fragment); glDeleteShader(fragment);
} }
~Shader() {
if (ID) {
glDeleteProgram(ID);
}
}
// 使用/激活程序 // 使用/激活程序
void use() { void use() {
glUseProgram(ID); glUseProgram(ID);
@@ -91,4 +99,13 @@ public:
void setFloat(const std::string& name, float value) const { void setFloat(const std::string& name, float value) const {
glUniform1f(glGetUniformLocation(ID, name.c_str()), value); 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));
}
}; };

View File

@@ -1,15 +1,19 @@
#version 330 core #version 330 core
out vec4 FragColor; out vec4 FragColor;
// 视口大小(像素,建议传入 framebuffer 尺寸HiDPI 下要乘 devicePixelRatio
uniform vec2 uViewport; uniform vec2 uViewport;
// 以像素为单位的网格间距:细网格/粗网格
uniform float uMinorStep; uniform float uMinorStep;
uniform float uMajorStep; uniform float uMajorStep;
// 生成抗锯齿网格线(返回 0..11 表示在线上)
float gridLine(float stepPx) { float gridLine(float stepPx) {
// 当前坐标像素
vec2 coord = gl_FragCoord.xy; vec2 coord = gl_FragCoord.xy;
vec2 q = coord / stepPx; vec2 q = coord / stepPx;
// 距离最近网格线的归一化距离,再用 fwidth 做抗锯齿
vec2 g = abs(fract(q - 0.5) - 0.5) / fwidth(q); vec2 g = abs(fract(q - 0.5) - 0.5) / fwidth(q);
float line = 1.0 - min(min(g.x, g.y), 1.0); float line = 1.0 - min(min(g.x, g.y), 1.0);
return line; return line;
@@ -17,21 +21,28 @@ float gridLine(float stepPx) {
void main() { void main() {
vec2 viewport = max(uViewport, vec2(1.0)); 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 topCol = vec3(0.99, 0.99, 1.00);
vec3 botCol = vec3(0.94, 0.95, 0.98); vec3 botCol = vec3(0.94, 0.95, 0.98);
vec3 col = mix(botCol, topCol, uv.y); vec3 col = mix(botCol, topCol, uv.y);
// 网格线:细线 + 粗线(每隔一段更深一点)
float minor = gridLine(max(uMinorStep, 1.0)); float minor = gridLine(max(uMinorStep, 1.0));
float major = gridLine(max(uMajorStep, 1.0)); float major = gridLine(max(uMajorStep, 1.0));
vec3 minorCol = vec3(0.80, 0.82, 0.87); 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, 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; vec2 p = uv * 2.0 - 1.0;
float v = clamp(1.0 - dot(p, p) * 0.12, 0.0, 1.0); float v = clamp(1.0 - dot(p, p) * 0.12, 0.0, 1.0);
col *= mix(1.0, v, 0.35); col *= mix(1.0, v, 0.35);
FragColor = vec4(col, 1.0); FragColor = vec4(col, 1.0);
} }

View File

@@ -1,6 +1,8 @@
#version 330 core #version 330 core
layout(location = 0) in vec2 aPos; // 全屏背景:直接在裁剪空间画一个矩形(不受相机/旋转影响)
layout(location = 0) in vec2 aPos; // NDC: [-1,1]
void main() { void main() {
gl_Position = vec4(aPos, 0.0, 1.0); gl_Position = vec4(aPos, 0.0, 1.0);
} }

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 spaceOpenGL 用 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);
}

View File

@@ -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);
}

View File

@@ -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);
}