Compare commits

11 Commits

Author SHA1 Message Date
c9495680d6 Merge branch 'main' of http://gitea.huangyanjie.com/lenn/tactileipc3d 2026-01-20 20:00:49 +08:00
4882dc1a67 update gitignore 2026-01-20 20:00:16 +08:00
bc9f2824ed 点阵完成,加入opencv 2026-01-20 19:55:56 +08:00
Lenn
54d285cbc8 fix:baud error 2026-01-20 11:13:31 +08:00
59564fd312 feat:write data pipeline into OpenGL Widget 2026-01-16 09:19:55 +08:00
053e247380 update README 2026-01-15 16:36:42 +08:00
02f5368b89 delete AUTO_PANEL_SIZING.md BACKGROUND_GRID.md 2026-01-15 16:20:34 +08:00
354552dc88 颜色映射图例,规格尺寸修改 2026-01-15 16:13:36 +08:00
f700dd360e Remove OpenXLSX submodule 2026-01-14 10:54:25 +08:00
d1aabeb8e3 Pin OpenXLSX to v0.3.2 2026-01-14 10:04:36 +08:00
e8dea24a16 Add OpenXLSX as submodel 2026-01-14 10:02:24 +08:00
34 changed files with 2108 additions and 654 deletions

2
.gitignore vendored
View File

@@ -11,7 +11,7 @@ TactileIpc3D_autogen/
*.ninja
*.ninja_deps
*.ninja_log
OpenCV/
# Qt generated files
*.moc
moc_*.cpp

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "3rdpart/QXlsx"]
path = 3rdpart/QXlsx
url = https://github.com/QtExcel/QXlsx.git

1
3rdpart/QXlsx Submodule

Submodule 3rdpart/QXlsx added at 9f545935a1

View File

@@ -1,151 +0,0 @@
# 自动适配:点阵不越界 + 面板尺寸跟随点阵
这份文档说明本项目里为了实现:
1. **圆点(点阵)始终落在面板顶面上,不越界**
2. **面板的宽/深W/D根据点阵规格自动缩放**
3. **面板厚度H保持独立可控不随点阵改变**
所做的代码改动和使用方式。
---
## 现象 & 根因
当前点阵的摆放逻辑(`src/glwidget.cpp``GLWidget::updateInstanceBufferIfNeeded_()`)是:
- 点阵中心围绕原点居中摆放
- 每个点的中心坐标:
- `x = c * pitch - (cols-1)*pitch/2`
- `z = r * pitch - (rows-1)*pitch/2`
- 所以 **点中心** 的范围一定在:
- `x ∈ [-gridW/2, +gridW/2]`,其中 `gridW = (cols-1)*pitch`
- `z ∈ [-gridD/2, +gridD/2]`,其中 `gridD = (rows-1)*pitch`
但每个点是“有半径”的(`dotRadius`),在 shader 里实际画出来的圆会占据:
- `x` 方向还会向外多出 `dotRadius`
- `z` 方向还会向外多出 `dotRadius`
如果面板的 `W/D` 没有随点阵变化而调整,就会出现你截图里那种:**外圈点的圆形被画到了面板外**(看起来像“越界”)。
---
## 解决思路(数学上怎么保证不越界)
要让“圆点的边缘”也不越界,需要满足:
- 面板半宽 `panelW/2 >= gridW/2 + dotRadius`
- 面板半深 `panelD/2 >= gridD/2 + dotRadius`
等价于:
- `panelW >= gridW + 2*dotRadius`
- `panelD >= gridD + 2*dotRadius`
本项目采用的就是这条公式。
> 如果你希望四周再留白一点,只要把公式改成:`+ 2*(dotRadius + margin)` 即可。
---
## 代码改动点(做了什么)
### 1) `setSpec()` 里自动计算面板 W/D
文件:`src/glwidget.cpp`,函数:`GLWidget::setSpec(...)`
做的事:
-`rows/cols/pitch/dotRadius` 做了下限保护(避免负数)
- 根据点阵规格计算面板顶面 `m_panelW/m_panelD`
- `gridW = (cols-1) * pitch`
- `gridD = (rows-1) * pitch`
- `m_panelW = gridW + 2*dotRadius`
- `m_panelD = gridD + 2*dotRadius`
- 标记面板几何和 dots 几何需要重建dirty flag
这样一旦你改点阵规格,面板会自动缩放到刚好包住点阵。
---
### 2) 厚度单独控制:`setPanelThickness()`
文件:`src/glwidget.h` / `src/glwidget.cpp`
新增接口:
- `void setPanelThickness(float h);`
它只修改 `m_panelH`(厚度),并标记面板几何需要重建。
---
### 3) 由于 W/D 会变:需要“重建几何体 buffer”
原因:面板顶面矩形的顶点坐标依赖 `m_panelW/m_panelD`,改变后必须重建 VBO/IBO/VAO 才能反映到 GPU。
做法:
- 新增两个 dirty flag
- `m_panelGeometryDirty`
- `m_dotsGeometryDirty`
-`GLWidget::paintGL()` 的开头判断 dirty
- dirty 就调用 `initPanelGeometry_()` / `initDotGeometry_()` 重建
- dots 重建后会重新分配 instanceVBO所以把 `m_valuesDirty = true`,确保下一帧重新上传 instance 数据
---
### 4) `initPanelGeometry_()` / `initDotGeometry_()` 现在会先删旧 buffer
因为会重复调用重建,所以这两个函数里加入了:
-`glDeleteBuffers` / `glDeleteVertexArrays`
- 再重新 `glGen*` 创建新资源
保证不会重复堆积资源(泄漏)或引用旧 VAO。
---
### 5) `main.cpp` 不再手动写死面板 W/D
文件:`main.cpp`
把原来的:
- `glw->setPanelSize(1.2f, 0.08f, 1.2f);`
改为:
- `glw->setPanelThickness(0.08f);`
原因:如果继续调用 `setPanelSize()`,你会把 `setSpec()` 自动算出来的 W/D 覆盖掉,越界问题就会回来。
---
## 使用方式(你应该怎么调用)
推荐顺序:
1. `glw->setSpec(rows, cols, pitch, dotRadius);`
- 会自动算出 `panelW/panelD`,保证点不越界
2. `glw->setPanelThickness(panelH);`
- 只改厚度,不影响自动宽深
3. `glw->setRange(minV, maxV);`
4. `glw->submitValues(values);`
如果你确实想手动指定面板大小(不走自动适配),可以再调用:
- `glw->setPanelSize(w, h, d);`
但要理解:这会覆盖自动计算的 `W/D`,点可能再次越界(除非你按本文公式算好)。
---
## 可选改进(按你需求继续拓展)
- **加边距(不要贴边)**:在 `setSpec()` 里引入 `margin`,把公式改成:
`m_panelW = gridW + 2*(m_dotRadius + margin)`
`m_panelD = gridD + 2*(m_dotRadius + margin)`
- **让点阵填满面板但不改变 pitch**:目前面板跟着 pitch 走;如果你希望面板固定而 pitch 自适配,需要反过来解 pitch。

View File

@@ -1,134 +0,0 @@
# 屏幕空间背景(白底 + 灰色网格线)说明
这份文档解释项目里新增的“3D 软件常见的白色背景 + 灰色分割线网格”是怎么实现的,以及如何调整效果。
需求点:
- 背景看起来更“科技感”(轻微渐变 + 网格线 + vignette
- **背景不随相机旋转**(不受 yaw/pitch/zoom 影响)
---
## 实现方式概览(为什么它不会旋转)
我们采用“屏幕空间screen-space”绘制方式
1. **画一个全屏 quad**(两个三角形),顶点坐标直接写在裁剪空间 NDC[-1,1]
2. fragment shader 使用 `gl_FragCoord`(屏幕像素坐标)生成网格线
由于整个背景是直接画在屏幕坐标系里,不使用 `uMVP`,所以它不会跟着相机旋转或移动。
---
## 关键文件
- 资源注册:`resources.qrc`
- 新增:`shaders/bg.vert``shaders/bg.frag`
- Shader
- `shaders/bg.vert`:全屏 quad 的顶点 shader裁剪空间直出
- `shaders/bg.frag`:背景颜色 + 抗锯齿网格线(基于 `gl_FragCoord`
- C++OpenGLWidget
- `src/glwidget.h`:新增 `m_bgProg``m_bgVao/m_bgVbo``initBackgroundGeometry_()`
- `src/glwidget.cpp`
- `GLWidget::initPrograms_()`:编译/链接背景 program
- `GLWidget::initBackgroundGeometry_()`:创建全屏 quad VAO/VBO
- `GLWidget::paintGL()`:先画背景(关闭深度写入),再画 3D 内容
---
## 渲染顺序(为什么不会影响 3D 深度)
背景绘制发生在 `GLWidget::paintGL()` 的最前面:
1. `glClear(...)` 清空颜色/深度
2. **绘制背景**(屏幕空间)
- `glDisable(GL_DEPTH_TEST);`
- `glDepthMask(GL_FALSE);`(不写深度)
3. 恢复深度状态
- `glDepthMask(GL_TRUE);`
- `glEnable(GL_DEPTH_TEST);`
4. 再绘制 panel / dots正常 3D 深度测试)
因此:背景永远在最底层,而且不会把深度缓冲弄脏。
---
## 全屏 quad背景几何
`GLWidget::initBackgroundGeometry_()` 创建一个覆盖整个屏幕的矩形(两个三角形):
- 顶点坐标是 NDC裁剪空间
- (-1,-1) 到 (1,1)
- 顶点 shader`shaders/bg.vert`)仅仅把它输出到 `gl_Position`
这样不需要任何相机矩阵,也不会“跟着相机”动。
---
## 网格线怎么画出来的bg.frag
`shaders/bg.frag` 主要做了三件事:
### 1) 背景渐变底色
- 使用 `uv = gl_FragCoord.xy / uViewport` 得到 0..1 的屏幕坐标
- 在 y 方向做一个轻微渐变(上更亮、下稍灰)
### 2) 细网格 + 粗网格(分割线)
`uMinorStep` / `uMajorStep`(单位:像素)控制网格间距:
- `uMinorStep`:细分格子(更淡)
- `uMajorStep`:粗分格子(更明显)
网格线本质是对 `fract(coord / step)` 做“距离最近线”的计算,然后用 `fwidth` 做抗锯齿:
- `fwidth` 会随着屏幕像素密度和视角变化自动调整边缘过渡,避免锯齿
### 3) 轻微 vignette
四角略暗、中心略亮,让画面更聚焦、更像 3D 软件视口。
---
## HiDPI 注意点(为什么要乘 devicePixelRatio
Qt 的 `width()/height()` 是“逻辑像素”;而 `gl_FragCoord` 是“物理像素”。
所以背景在 C++ 里传入:
- `uViewport = (width * dpr, height * dpr)`
- 网格 step 也乘 `dpr`
否则在高 DPI 屏幕上网格会显得“变密/变粗”。
---
## 如何调整外观(常用参数)
### 调整网格密度
`src/glwidget.cpp` 里设置了:
- `uMinorStep = 24 px`(细线间距)
- `uMajorStep = 120 px`(粗线间距)
改这两个值就能让网格更密/更稀。
### 调整颜色/强度/科技感
`shaders/bg.frag` 里可以改:
- `topCol/botCol`:背景渐变颜色
- `minorCol/majorCol`:网格线颜色
- `mix(...)` 的系数:线条深浅
- vignette 强度:`dot(p,p)` 前面的系数
---
## 可选增强(如果你想更像 Blender/Unity
- 加“坐标轴线”X 红、Z 蓝或灰色加深)并在中心画十字
- 增加 UI 开关:显示/隐藏网格、调 step、调强度
- 增加“地平线”或 “ground plane” 的淡淡雾化效果

View File

@@ -9,7 +9,6 @@ set(CMAKE_AUTOUIC ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src)
find_package(Qt6 COMPONENTS
Core
@@ -22,9 +21,14 @@ find_package(Qt6 COMPONENTS
Quick
QuickControls2
QuickLayouts
QuickDialogs2
LinguistTools
)
set(QT_VERSION_MAJOR 6)
add_subdirectory(3rdpart/QXlsx/QXlsx)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src)
qt_standard_project_setup()
add_executable(TactileIpc3D
@@ -59,8 +63,10 @@ add_executable(TactileIpc3D
src/ringbuffer.cpp
src/sparkline_plotitem.h
src/sparkling_plotitem.cpp
src/globalhelper.h
src/globalhelper.h
)
target_link_libraries(TactileIpc3D
target_link_libraries(TactileIpc3D PRIVATE
Qt6::Core
Qt6::Gui
Qt6::Widgets
@@ -70,8 +76,12 @@ target_link_libraries(TactileIpc3D
Qt6::Quick
Qt6::QuickControls2
Qt6::QuickLayouts
Qt6::QuickDialogs2
QXlsx::QXlsx
)
target_include_directories(TactileIpc3D PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/3rdpart/OpenCV/include
)
set(TS_FILES
${CMAKE_CURRENT_SOURCE_DIR}/i18n/app_zh_CN.ts
${CMAKE_CURRENT_SOURCE_DIR}/i18n/app_en_US.ts
@@ -104,31 +114,17 @@ qt_add_resources(TactileIpc3D i18n_resources
FILES ${QM_FILES}
)
#if (WIN32 AND NOT DEFINED CMAKE_TOOLCHAIN_FILE)
# set(DEBUG_SUFFIX)
# if (MSVC AND CMAKE_BUILD_TYPE MATCHES "Debug")
# set(DEBUG_SUFFIX "d")
# endif ()
# set(QT_INSTALL_PATH "${CMAKE_PREFIX_PATH}")
# if (NOT EXISTS "${QT_INSTALL_PATH}/bin")
# set(QT_INSTALL_PATH "${QT_INSTALL_PATH}/..")
# if (NOT EXISTS "${QT_INSTALL_PATH}/bin")
# set(QT_INSTALL_PATH "${QT_INSTALL_PATH}/..")
# endif ()
# endif ()
# if (EXISTS "${QT_INSTALL_PATH}/plugins/platforms/qwindows${DEBUG_SUFFIX}.dll")
# add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
# COMMAND ${CMAKE_COMMAND} -E make_directory
# "$<TARGET_FILE_DIR:${PROJECT_NAME}>/plugins/platforms/")
# add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
# COMMAND ${CMAKE_COMMAND} -E copy
# "${QT_INSTALL_PATH}/plugins/platforms/qwindows${DEBUG_SUFFIX}.dll"
# "$<TARGET_FILE_DIR:${PROJECT_NAME}>/plugins/platforms/")
# endif ()
# foreach (QT_LIB Core Gui Widgets)
# add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
# COMMAND ${CMAKE_COMMAND} -E copy
# "${QT_INSTALL_PATH}/bin/Qt6${QT_LIB}${DEBUG_SUFFIX}.dll"
# "$<TARGET_FILE_DIR:${PROJECT_NAME}>")
# endforeach (QT_LIB)
#endif ()
set(runtime_out_dir "${CMAKE_BINARY_DIR}/out")
set_target_properties(TactileIpc3D PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${runtime_out_dir}"
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${runtime_out_dir}/Debug"
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${runtime_out_dir}/Release"
RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${runtime_out_dir}/RelWithDebInfo"
RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${runtime_out_dir}/MinSizeRel"
)
include(GNUInstallDirs)
install(TARGETS TactileIpc3D
RUNTIME DESTINATION bin
)

1
Configure Normal file
View File

@@ -0,0 +1 @@
==

View File

@@ -257,29 +257,38 @@ m_dotsProg->release();
- 想要更柔和的圆边:用 `smoothstep` 做 alpha 边缘 + 开启 blending
- 想要真正的 3D 圆柱/球:就不能靠 discard 了,需要真正的几何或法线光照
## Metrics ????
## Metrics(指标计算)
Metrics ?????????? payload?`DataFrame.data`??????? `metricsChanged` ???
Metrics 是对**单帧 payload**的统计结果payload 即 `DataFrame.data`。每次接收新帧或回放 tick
`DataBackend::emitFrame_()` 会先调用 `updateMetrics_()`,然后发出 `metricsChanged`QML 端通过
`Backend.data.metric*` 读取这些值。
- ??????
- ????sqrt(mean(v^2))
- ????mean(v)
- ??????? - ???
- Peak`max(v)`
- RMS`sqrt(mean(v^2))`
- Avg`mean(v)`
- Delta`max(v) - min(v)`
- Sum`sum(v)`
## Live Trend ??
代码位置:`src/data_backend.cpp``updateMetrics_()`
Live Trend ????? payload ???sum of `DataFrame.data`???????????????? `src/data_backend.cpp` ? `updateMetrics_()` ???? `metricSum`??? `qml/content/RightPanel.qml` ??? `Backend.data.metricSum` ??????
## Live TrendPayload Sum
## Live Trend ??????????
右侧面板的 **Payload Sum** 折线图来自 `metricSum`:每次 `metricsChanged` 时,如果已有帧数据,
就会执行 `card.plot.append(Backend.data.metricSum)`
????????? `QImage` ?????????? DPI?devicePixelRatio??????? DPI ??????????????????????? y ???????????????????????
- 计算来源:`DataBackend::updateMetrics_()``metricSum = sum(DataFrame.data)`
- 触发位置:`qml/content/RightPanel.qml``Connections { onMetricsChanged { ... } }`
- 绘图组件:`qml/content/LiveTrendCard.qml` + `SparklinePlot``LiveTrend` 模块)
?????
- ???????? `devicePixelRatio` ???????
- ????????????????????
- ???????? y ????????????????
## Live Trend 的 HiDPI 对齐(文字清晰度/位置)
?????????
SparklinePlot 的刻度文字通过 `QImage -> QSGTexture` 绘制。为避免高 DPI 下文字发虚或 y 方向偏移,
实现上做了两件事:
-`devicePixelRatio` 生成缓存 key 与 QImage 像素尺寸
- `QImage::setDevicePixelRatio(dpr)`,并在布局时按像素网格对齐
相关实现:
```cpp
// src/sparkline_plotitem.h
@@ -320,16 +329,23 @@ QSGNode* SparklinePlotItem::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeDat
}
```
## Export Logic
## Export Logic(导出流程)
Export is driven by the Save dialog and DataBackend::exportHandler():
导出由保存对话框驱动,最终落到 `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.
- 触发入口:`qml/content/LeftPanel.qml``SaveAsExportDialog``Backend.data.exportHandler(...)`
- `folder` 为本地 `QUrl`(如 `file:///C:/...`C++ 端会转换为本地路径
- `format``csv` / `json` / `xlsx`(为空时会根据文件后缀推断)
- `method``overwrite`(默认) / `append` / `zip`UI 有入口但后端未实现压缩)
Relevant code: src/data_backend.cpp (exportHandler, uildCsv_, uildJson_), qml/content/SaveAsExportDialog.qml.
追加行为:
- CSV append以追加模式写入 `buildCsv_()` 的内容(无表头)
- JSON append读取已有数组如果存在追加当前帧后整体重写
当前限制:
- `xlsx` 通过QXlsx是实现但是缺少传感器没有测试
- `zip` 未实现压缩逻辑(请视为暂不支持)
相关代码:`src/data_backend.cpp``exportHandler` / `buildCsv_` / `buildJson_``qml/content/SaveAsExportDialog.qml`

1
VERSION.txt Normal file
View File

@@ -0,0 +1 @@
1.0.0

View File

@@ -10,8 +10,18 @@ classDiagram
+bool connected
+SerialBackend* serial
+DataBackend* data
+int rangeMin
+int rangeMax
+QColor colorLow
+QColor colorMid
+QColor colorHigh
+setLightMode(bool)
+setLanguage(string)
+setRangeMin(int)
+setRangeMax(int)
+setColorLow(QColor)
+setColorMid(QColor)
+setColorHigh(QColor)
}
class SerialConfig {
@@ -60,6 +70,10 @@ classDiagram
+setTransport(transport)
}
class GLWidget {
+setRange(int, int)
+setColorLow(QColor)
+setColorMid(QColor)
+setColorHigh(QColor)
+dotClicked(index, row, col, value)
}
@@ -160,6 +174,7 @@ classDiagram
AppBackend --> SerialBackend
AppBackend --> DataBackend
AppBackend ..> GLWidget : render config
SerialBackend --> SerialConfig
SerialBackend --> SensorRequest
SerialBackend --> SensorSpec
@@ -248,6 +263,15 @@ flowchart TD
SB -->|updateProtocolBindings_| SW3[SerialSendWorker.setBuildRequestFunc]
```
## 渲染/颜色映射流程 (Mermaid)
```mermaid
flowchart LR
UI[LeftPanel 颜色映射] -->|rangeMin/rangeMax<br/>colorLow/Mid/High| AB[AppBackend]
AB -->|rangeChanged/colorChanged| GL[GLWidget]
GL -->|uMinV/uMaxV/uColorLow/Mid/High| SH[dots.frag]
```
## 配置接口与步骤说明
- 设置协议:
@@ -272,6 +296,11 @@ flowchart TD
- `SerialSendWorker::setBuildRequestFunc(codec->buildRequest)`
- 打开串口:
- `SerialBackend::open()` -> `SerialSendWorker::openTransport(config)`,成功后在从站模式启动轮询发送。
- 渲染/颜色映射配置:
- QML 绑定 `AppBackend::rangeMin/rangeMax``colorLow/Mid/High``LeftPanel` 中的颜色映射面板)。
- `setRangeMin/Max` 发出 `rangeChanged(min, max)`,由 `GLWidget::setRange` 同步到 `uMinV/uMaxV`
- `setColorLow/Mid/High` 发出 `color*Changed`,由 `GLWidget::setColor*` 同步到 `uColorLow/Mid/High`
- `dots.frag` 归一化公式:`value01 = clamp((v - min) / (max - min))`,并用 low->mid->high 线性插值。
## 分层设计概述
@@ -279,6 +308,7 @@ flowchart TD
- 串口采集层:`Transport + Format + Codec + Decoder + Manager` 分层,独立于业务逻辑。
- 串口线程化:读取/解码/发送三线程 + Packet/Frame 队列,降低 UI 卡顿风险。
- 数据驱动层:负责帧缓存、数据导入导出与回放,提供渲染回调。
- 渲染配置层:`AppBackend` 提供范围/颜色参数,`GLWidget` 将其转为 shader uniform 进行颜色映射。
- UI 层:`NavBar + LeftPanel + OpenGL View + RightPanel` 的 1+3 布局。
## 类接口与成员说明C++
@@ -289,11 +319,16 @@ flowchart TD
- `lightMode` / `language` / `connected`UI 基础状态。
- `serial()` / `data()`:暴露子系统实例给 QML。
- `setLightMode(bool)` / `setLanguage(string)`
- `rangeMin` / `rangeMax`:颜色映射的数值范围。
- `colorLow` / `colorMid` / `colorHigh`:颜色映射的三个基准颜色。
- `setRangeMin(int)` / `setRangeMax(int)` / `setColorLow(QColor)` / `setColorMid(QColor)` / `setColorHigh(QColor)`
- 成员变量:
- `m_serial`:串口采集层对象。
- `m_data`:数据驱动层对象。
- `m_lightMode` / `m_language`:全局 UI 状态。
- `m_rangeMin` / `m_rangeMax` / `m_colorLow` / `m_colorMid` / `m_colorHigh`:颜色映射参数。
- 备注:串口连接成功后会清空历史数据缓存,避免旧数据残留。
- `rangeChanged(min, max)` 用于驱动 `GLWidget::setRange`,颜色变更通过 `color*Changed` 同步。
### SerialBackend (`src/serial/serial_backend.h`)
- 作用:串口采集层的统一控制器,负责协议选择、三线程调度与数据分发。
@@ -395,9 +430,14 @@ flowchart TD
### GLWidget (`src/glwidget.h`)
- 作用OpenGL 渲染窗口,显示传感器点阵与背景。
- 接口:
- `setRange(int minV, int maxV)`:设置 `uMinV/uMaxV`
- `setColorLow(QColor)` / `setColorMid(QColor)` / `setColorHigh(QColor)`:设置 `uColorLow/uColorMid/uColorHigh`
- 信号:
- `dotClicked(index, row, col, value)`:鼠标点击某个点时发出索引与数据值。
- 备注:拾取使用屏幕投影 + 半径阈值,便于 RightPanel 订阅点击事件做曲线展示。
- 备注:
- 颜色映射在 `dots.frag` 内完成,低/中/高三段线性插值。
- 拾取使用屏幕投影 + 半径阈值,便于 RightPanel 订阅点击事件做曲线展示。
### DataFrame (`src/data_frame.h`)
- 字段:
@@ -440,6 +480,9 @@ flowchart TD
- 采样周期(从站模式可用)。
- 采样参数:功能码、起始地址、读取长度。
- 传感器规格:协议名、型号、网格规格占位。
- 颜色映射:
- 数值范围min/max
- 低/中/高三色选择(`ColorDialog`)。
- 显示控制:显示网络/坐标轴、回放与导出入口。
### RightPanel`qml/content/RightPanel.qml`
@@ -518,6 +561,7 @@ flowchart TD
## 更新记录
- 2026-01-11新增 `QtSerialTransport``QSerialPort` 传输实现)并设为默认传输;补齐点选拾取逻辑;新增数据流/时序图并补充可视化 TODO 说明。
- 2026-01-12`LeftPanel` 新增颜色映射参数;`AppBackend`/`GLWidget` 增加颜色与范围接口shader 使用三色渐变映射数据值。
- 2026-01-11补充串口配置流程图与配置接口说明协议/参数/解码器绑定/打开流程)。
- 2026-01-05新增串口三线程流水线读/解码/发送)与 Packet/Frame 队列,更新协议起始符说明,补充队列溢出 TODO 与线程组件文档。
- 2026-01-05CollapsiblePanel 组件改为跟随 `backend.lightMode` 切换暗色主题配色。

View File

@@ -96,6 +96,14 @@
<source></source>
<translation>Sample Interval</translation>
</message>
<message>
<source></source>
<translation>Width</translation>
</message>
<message>
<source></source>
<translation>Height</translation>
</message>
<message>
<source></source>
<translation>Connect</translation>
@@ -140,6 +148,34 @@
<source></source>
<translation>Rescan</translation>
</message>
<message>
<source></source>
<translation>Color Mapping</translation>
</message>
<message>
<source></source>
<translation>Min Value</translation>
</message>
<message>
<source></source>
<translation>Max Value</translation>
</message>
<message>
<source></source>
<translation>Low Color</translation>
</message>
<message>
<source></source>
<translation>Select</translation>
</message>
<message>
<source></source>
<translation>Mid Color</translation>
</message>
<message>
<source></source>
<translation>High Color</translation>
</message>
<message>
<source></source>
<translation>Display</translation>
@@ -160,6 +196,45 @@
<source></source>
<translation>Export Data</translation>
</message>
<message>
<source></source>
<translation>Select Low Color</translation>
</message>
<message>
<source></source>
<translation>Select Mid Color</translation>
</message>
<message>
<source></source>
<translation>Select High Color</translation>
</message>
</context>
<context>
<name>OpenFileDialog</name>
<message>
<source></source>
<translation>Import Data</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>
</context>
<context>
<name>RightPanel</name>
@@ -171,6 +246,10 @@
<source>Live Trend</source>
<translation>Live Trend</translation>
</message>
<message>
<source>Legend</source>
<translation>Legend</translation>
</message>
<message>
<source>Metrics</source>
<translation>Metrics</translation>

View File

@@ -171,6 +171,10 @@
<source>Live Trend</source>
<translation></translation>
</message>
<message>
<source>Legend</source>
<translation></translation>
</message>
<message>
<source>Metrics</source>
<translation></translation>

View File

@@ -0,0 +1,52 @@
#define MyAppName "TactileIpc3D"
#define MyAppVersion "0.2.0"
#define MyAppPublisher "TactileIpc3D"
#define MyAppExeName "TactileIpc3D.exe"
#define SourceDir "..\\build\Desktop_Qt_6_8_3_MinGW_64_bit-Release\\out\Release"
#define OutputDir "..\\dist"
#define AssetsDir "assets"
#define AppIconFile AssetsDir + "\\App.ico"
#define WizardSmallFile AssetsDir + "\\WizardSmall.bmp"
#define WizardLargeFile AssetsDir + "\\WizardLarge.bmp"
[Setup]
AppId={{1E4C86D4-4E53-4B8A-9F7F-1D56E8E04353}}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
OutputDir={#OutputDir}
OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}
Compression=lzma
SolidCompression=yes
WizardStyle=modern
SetupLogging=yes
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
#ifexist AppIconFile
SetupIconFile={#AppIconFile}
#endif
#ifexist WizardSmallFile
WizardSmallImageFile={#WizardSmallFile}
#endif
#ifexist WizardLargeFile
WizardImageFile={#WizardLargeFile}
#endif
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
; Name: "chinesesimp"; MessagesFile: "compiler:Languages\\ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "{#SourceDir}\\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{group}\\{#MyAppName}"; Filename: "{app}\\{#MyAppExeName}"
Name: "{autodesktop}\\{#MyAppName}"; Filename: "{app}\\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: postinstall nowait skipifsilent

View File

@@ -0,0 +1,6 @@
Place installer assets here:
- App.ico
- WizardSmall.bmp
- WizardLarge.bmp
If these files are missing, the Inno Setup script will skip the custom icon/images.

View File

@@ -44,7 +44,8 @@ int main(int argc, char *argv[]) {
QOpenGLContext probeCtx;
probeCtx.setFormat(QSurfaceFormat::defaultFormat());
if (!probeCtx.create()) {
qCritical().noquote() << "Failed to create an OpenGL context (required: OpenGL 3.3 Core).";
qCritical().noquote() << "Failed to create an OpenGL context "
"(required: OpenGL 3.3 Core).";
return 1;
}
@@ -54,17 +55,22 @@ int main(int argc, char *argv[]) {
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.";
<< "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);
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() << ").";
<< actual.majorVersion() << "." << actual.minorVersion()
<< ", profile=" << actual.profile() << ").";
return 1;
}
@@ -84,12 +90,12 @@ int main(int argc, char *argv[]) {
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());
});
QObject::connect(
&backend, &AppBackend::languageChanged, &i18n,
[&backend, &i18n]() { i18n.setLanguage(backend.language()); });
auto *qmlEngine = new QQmlEngine(root);
auto createQuickWidget = [&](const QUrl& sourceUrl) -> QQuickWidget* {
auto createQuickWidget = [&](const QUrl &sourceUrl) -> QQuickWidget * {
auto *view = new QQuickWidget(qmlEngine, root);
view->setResizeMode(QQuickWidget::SizeRootObjectToView);
view->setSource(sourceUrl);
@@ -107,26 +113,27 @@ int main(int argc, char *argv[]) {
leftView->setFixedWidth(350);
auto *glw = new GLWidget;
glw->setSpec(8, 11, 0.1f, 0.03f);
glw->setSpec(12, 7, 0.1f, 0.03f);
glw->setPanelThickness(0.08f);
glw->setRange(0, 1000);
glw->setRange(backend.rangeMin(), backend.rangeMax());
glw->setColorLow(backend.colorLow());
glw->setColorMid(backend.colorMid());
glw->setColorHigh(backend.colorHigh());
/* backend.data()->setLiveRenderCallback([glw](const DataFrame& frame) {
if (frame.data.size() != glw->dotCount())
return;
glw->submitValues(frame.data);
}); */
backend.data()->setLiveRenderCallback([](const DataFrame& frame) {
backend.data()->setLiveRenderCallback([glw](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();
glw->submitValues(frame.data);
}
});
// TODO:待实现内容(将frame数据分发给右侧曲线/指标的QML接口)
auto *rightView = createQuickWidget(QUrl("qrc:/qml/content/RightPanel.qml"));
auto *rightView =
createQuickWidget(QUrl("qrc:/qml/content/RightPanel.qml"));
splitter->addWidget(leftView);
splitter->addWidget(glw);
@@ -136,33 +143,52 @@ int main(int argc, char *argv[]) {
splitter->setStretchFactor(2, 0);
splitter->setSizes({320, 640, 320});
auto applySplitterStyle = [&backend, splitter]() {
const QString handleColor = backend.lightMode() ? QStringLiteral("#E0E0E0") : QStringLiteral("#2C2C2C");
splitter->setStyleSheet(QStringLiteral("QSplitter::handle { background: %1; }").arg(handleColor));
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();
});
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"));
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, navView,
[applyQuickTheme]() { applyQuickTheme(); });
QObject::connect(&backend, &AppBackend::lightModeChanged, glw,
[&backend, glw]() {
bool m = backend.lightMode() ? true : false;
glw->setLightMode(m);
});
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);
QObject::connect(&backend, &AppBackend::showGridChanged, glw,
&GLWidget::setShowGrid);
QObject::connect(&backend, &AppBackend::sensorRowChanged, glw,
&GLWidget::setRow);
QObject::connect(&backend, &AppBackend::sensorColChanged, glw,
&GLWidget::setCol);
QObject::connect(&backend, &AppBackend::rangeChanged, glw,
&GLWidget::setRange);
QObject::connect(&backend, &AppBackend::colorLowChanged, glw,
&GLWidget::setColorLow);
QObject::connect(&backend, &AppBackend::colorMidChanged, glw,
&GLWidget::setColorMid);
QObject::connect(&backend, &AppBackend::colorHighChanged, glw,
&GLWidget::setColorHigh);
rootLayout->addWidget(navView);
rootLayout->addWidget(splitter);

View File

@@ -6,7 +6,7 @@ import TactileIPC 1.0
Item {
id: root
width: 350
implicitWidth: 350
property alias title: titleText.text
property bool expanded: true

View File

@@ -0,0 +1,616 @@
// ColorPickerWindow.qml
import QtQuick
import QtQuick.Window
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Controls.Material
import TactileIPC 1.0
Window {
id: root
width: 360
height: 650
visible: false
modality: Qt.ApplicationModal
flags: Qt.Dialog
title: qsTr("颜色选择")
Material.theme: Backend.lightMode ? Material.Light : Material.Dark
Material.accent: Material.Green
Material.primary: Material.Green
readonly property bool isDark: !Backend.lightMode
readonly property color windowBg: isDark ? "#1F1F1F" : "#F7F7F7"
readonly property color panelBorder: isDark ? "#2E2E2E" : "#D9D9D9"
readonly property color surfaceBorder: isDark ? "#343434" : "#CFCFCF"
readonly property color textPrimary: isDark ? "#EDEDED" : "#1F1F1F"
readonly property color textSecondary: isDark ? "#D8D8D8" : "#616161"
readonly property color textMuted: isDark ? "#BEBEBE" : "#6E6E6E"
readonly property color controlBg: isDark ? "#2A2A2A" : "#FFFFFF"
readonly property color controlBorder: isDark ? "#3A3A3A" : "#CFCFCF"
readonly property color highlightBg: isDark ? "#55FFFFFF" : "#33000000"
readonly property color highlightText: isDark ? "#111111" : "#FFFFFF"
readonly property color indicatorBorder: isDark ? "#6A6A6A" : "#9E9E9E"
readonly property color accentColor: Material.color(Material.Green)
// ===== API =====
property color color: "#FF7032D2" // AARRGGBB
signal accepted(color c)
signal rejected()
function openWith(c) {
syncFromColor(c ?? root.color)
visible = true
requestActivate()
}
Keys.onEscapePressed: {
visible = false
rejected()
}
// ===== internal HSV(A) =====
property real h: 0.75 // 0..1
property real s: 0.45
property real v: 0.82
property real a: 1.0
property bool _lock: false
function clamp01(x) { return Math.max(0, Math.min(1, x)) }
function clampInt(x, lo, hi) { return Math.max(lo, Math.min(hi, Math.round(x))) }
function hsvToRgb(hh, ss, vv) {
const h6 = (hh % 1) * 6
const c = vv * ss
const x = c * (1 - Math.abs((h6 % 2) - 1))
const m = vv - c
let r1=0, g1=0, b1=0
if (0 <= h6 && h6 < 1) { r1=c; g1=x; b1=0 }
else if (1 <= h6 && h6 < 2) { r1=x; g1=c; b1=0 }
else if (2 <= h6 && h6 < 3) { r1=0; g1=c; b1=x }
else if (3 <= h6 && h6 < 4) { r1=0; g1=x; b1=c }
else if (4 <= h6 && h6 < 5) { r1=x; g1=0; b1=c }
else { r1=c; g1=0; b1=x }
return { r: r1 + m, g: g1 + m, b: b1 + m }
}
function rgbToHsv(r, g, b) {
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const d = max - min
let hh = 0
if (d !== 0) {
if (max === r) hh = ((g - b) / d) % 6
else if (max === g) hh = (b - r) / d + 2
else hh = (r - g) / d + 4
hh /= 6
if (hh < 0) hh += 1
}
const ss = (max === 0) ? 0 : d / max
const vv = max
return { h: hh, s: ss, v: vv }
}
function toHex2(v01) {
const n = clampInt(clamp01(v01) * 255, 0, 255)
const s = n.toString(16).toUpperCase()
return (s.length === 1) ? ("0" + s) : s
}
function colorToHexAARRGGBB(c) {
return "#" + toHex2(c.a) + toHex2(c.r) + toHex2(c.g) + toHex2(c.b)
}
function parseHex(str) {
let t = ("" + str).trim()
if (t.startsWith("0x") || t.startsWith("0X")) t = t.slice(2)
if (t.startsWith("#")) t = t.slice(1)
if (t.length === 6) {
const rr = parseInt(t.slice(0,2), 16)
const gg = parseInt(t.slice(2,4), 16)
const bb = parseInt(t.slice(4,6), 16)
if ([rr,gg,bb].some(x => isNaN(x))) return null
return Qt.rgba(rr/255, gg/255, bb/255, 1)
}
if (t.length === 8) {
const aa = parseInt(t.slice(0,2), 16)
const rr = parseInt(t.slice(2,4), 16)
const gg = parseInt(t.slice(4,6), 16)
const bb = parseInt(t.slice(6,8), 16)
if ([aa,rr,gg,bb].some(x => isNaN(x))) return null
return Qt.rgba(rr/255, gg/255, bb/255, aa/255)
}
return null
}
function syncFromHSV() {
if (_lock) return
_lock = true
const rgb = hsvToRgb(h, s, v)
color = Qt.rgba(rgb.r, rgb.g, rgb.b, a)
// 更新 UI
hexField.text = colorToHexAARRGGBB(color)
alphaPercent.text = clampInt(a*100, 0, 100) + "%"
rField.value = clampInt(color.r * 255, 0, 255)
gField.value = clampInt(color.g * 255, 0, 255)
bField.value = clampInt(color.b * 255, 0, 255)
// HSV 显示用度/百分比
hField.value = clampInt(h * 360, 0, 360)
sField.value = clampInt(s * 100, 0, 100)
vField.value = clampInt(v * 100, 0, 100)
svCanvas.requestPaint()
hueCanvas.requestPaint()
alphaCanvas.requestPaint()
_lock = false
}
function syncFromColor(c) {
if (_lock) return
_lock = true
const hsv = rgbToHsv(c.r, c.g, c.b)
h = hsv.h; s = hsv.s; v = hsv.v; a = c.a
color = Qt.rgba(c.r, c.g, c.b, a)
hexField.text = colorToHexAARRGGBB(color)
alphaPercent.text = clampInt(a*100, 0, 100) + "%"
rField.value = clampInt(c.r * 255, 0, 255)
gField.value = clampInt(c.g * 255, 0, 255)
bField.value = clampInt(c.b * 255, 0, 255)
hField.value = clampInt(h * 360, 0, 360)
sField.value = clampInt(s * 100, 0, 100)
vField.value = clampInt(v * 100, 0, 100)
svCanvas.requestPaint()
hueCanvas.requestPaint()
alphaCanvas.requestPaint()
_lock = false
}
function applyRGB(r255, g255, b255) {
if (_lock) return
const rr = clampInt(r255, 0, 255) / 255
const gg = clampInt(g255, 0, 255) / 255
const bb = clampInt(b255, 0, 255) / 255
const hsv = rgbToHsv(rr, gg, bb)
h = hsv.h; s = hsv.s; v = hsv.v
syncFromHSV()
}
function applyHSV(hDeg, sPct, vPct) {
if (_lock) return
h = clamp01(hDeg / 360)
s = clamp01(sPct / 100)
v = clamp01(vPct / 100)
syncFromHSV()
}
// 初始同步
Component.onCompleted: syncFromColor(root.color)
// ====== UI: 深色窗口面板(不是卡片)======
Rectangle {
anchors.fill: parent
radius: 0
color: root.windowBg
border.width: 1
border.color: root.panelBorder
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 10
// 顶部:标题 + 勾选(参考图右上角)
// RowLayout {
// Layout.fillWidth: true
// spacing: 8
// Label {
// text: root.title
// color: root.textPrimary
// font.pixelSize: 14
// font.weight: Font.DemiBold
// Layout.fillWidth: true
// }
// CheckBox {
// id: alphaToggle
// checked: true
// contentItem: Label {
// text: qsTr("实时预览")
// color: root.textSecondary
// verticalAlignment: Text.AlignVCenter
// elide: Text.ElideRight
// }
// indicator: Rectangle {
// implicitWidth: 16
// implicitHeight: 16
// radius: 3
// border.width: 1
// border.color: root.indicatorBorder
// color: alphaToggle.checked ? root.accentColor : "transparent"
// // 选中勾
// Canvas {
// anchors.fill: parent
// onPaint: {
// const ctx = getContext("2d")
// ctx.clearRect(0,0,width,height)
// if (!alphaToggle.checked) return
// ctx.strokeStyle = "white"
// ctx.lineWidth = 2
// ctx.lineCap = "round"
// ctx.beginPath()
// ctx.moveTo(width*0.25, height*0.55)
// ctx.lineTo(width*0.45, height*0.72)
// ctx.lineTo(width*0.78, height*0.30)
// ctx.stroke()
// }
// }
// }
// }
// ToolButton {
// text: "✕"
// onClicked: { root.visible = false; root.rejected() }
// }
// }
// HSV 面板
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 185
radius: 8
border.width: 1
border.color: root.surfaceBorder
clip: true
Canvas {
id: svCanvas
anchors.fill: parent
onPaint: {
const ctx = getContext("2d")
const w = width, hh = height
ctx.clearRect(0,0,w,hh)
const rgbHue = root.hsvToRgb(root.h, 1, 1)
// X: white -> hue
const gx = ctx.createLinearGradient(0,0,w,0)
gx.addColorStop(0, "rgb(255,255,255)")
gx.addColorStop(1, "rgb(" + Math.round(rgbHue.r*255) + "," + Math.round(rgbHue.g*255) + "," + Math.round(rgbHue.b*255) + ")")
ctx.fillStyle = gx
ctx.fillRect(0,0,w,hh)
// Y: transparent -> black
const gy = ctx.createLinearGradient(0,0,0,hh)
gy.addColorStop(0, "rgba(0,0,0,0)")
gy.addColorStop(1, "rgba(0,0,0,1)")
ctx.fillStyle = gy
ctx.fillRect(0,0,w,hh)
// handle
const x = root.s * w
const y = (1 - root.v) * hh
ctx.beginPath()
ctx.arc(x, y, 7, 0, Math.PI*2)
ctx.lineWidth = 2
ctx.strokeStyle = "rgba(255,255,255,0.95)"
ctx.stroke()
ctx.beginPath()
ctx.arc(x, y, 6, 0, Math.PI*2)
ctx.lineWidth = 1
ctx.strokeStyle = "rgba(0,0,0,0.55)"
ctx.stroke()
}
}
MouseArea {
anchors.fill: parent
function apply(mx, my) {
root.s = root.clamp01(mx / width)
root.v = root.clamp01(1 - (my / height))
root.syncFromHSV()
}
onPressed: (e) => apply(e.x, e.y)
onPositionChanged: (e) => { if (pressed) apply(e.x, e.y) }
}
}
// Hue 彩虹条(参考图那种)
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 16
radius: 8
border.width: 1
border.color: root.surfaceBorder
clip: true
Canvas {
id: hueCanvas
anchors.fill: parent
onPaint: {
const ctx = getContext("2d")
const w = width, hh = height
ctx.clearRect(0,0,w,hh)
const grad = ctx.createLinearGradient(0,0,w,0)
for (let i=0; i<=6; i++) {
const t = i/6
const rgb = root.hsvToRgb(t, 1, 1)
grad.addColorStop(t, "rgb(" + Math.round(rgb.r*255) + "," + Math.round(rgb.g*255) + "," + Math.round(rgb.b*255) + ")")
}
ctx.fillStyle = grad
ctx.fillRect(0,0,w,hh)
const x = root.h * w
ctx.beginPath()
ctx.arc(x, hh/2, 7, 0, Math.PI*2)
ctx.lineWidth = 2
ctx.strokeStyle = "rgba(255,255,255,0.95)"
ctx.stroke()
ctx.beginPath()
ctx.arc(x, hh/2, 6, 0, Math.PI*2)
ctx.lineWidth = 1
ctx.strokeStyle = "rgba(0,0,0,0.55)"
ctx.stroke()
}
}
MouseArea {
anchors.fill: parent
function apply(mx) {
root.h = root.clamp01(mx / width)
root.syncFromHSV()
}
onPressed: (e) => apply(e.x)
onPositionChanged: (e) => { if (pressed) apply(e.x) }
}
}
// Alpha 条(棋盘 + 渐变 + 右侧圆点)
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 16
radius: 8
border.width: 1
border.color: root.surfaceBorder
clip: true
// checker
Canvas {
id: checkerCanvas
anchors.fill: parent
onPaint: {
const ctx = getContext("2d")
const w = width, hh = height
ctx.clearRect(0,0,w,hh)
const s = 8
for (let y=0; y<hh; y+=s) {
for (let x=0; x<w; x+=s) {
const on = ((x/s + y/s) % 2) === 0
ctx.fillStyle = on ? "rgba(255,255,255,0.16)" : "rgba(0,0,0,0.0)"
ctx.fillRect(x,y,s,s)
}
}
}
}
Canvas {
id: alphaCanvas
anchors.fill: parent
onPaint: {
const ctx = getContext("2d")
const w = width, hh = height
ctx.clearRect(0,0,w,hh)
const rgb = root.hsvToRgb(root.h, root.s, root.v)
const grad = ctx.createLinearGradient(0,0,w,0)
grad.addColorStop(0, "rgba(" + Math.round(rgb.r*255) + "," + Math.round(rgb.g*255) + "," + Math.round(rgb.b*255) + ",0)")
grad.addColorStop(1, "rgba(" + Math.round(rgb.r*255) + "," + Math.round(rgb.g*255) + "," + Math.round(rgb.b*255) + ",1)")
ctx.fillStyle = grad
ctx.fillRect(0,0,w,hh)
const x = root.a * w
ctx.beginPath()
ctx.arc(x, hh/2, 7, 0, Math.PI*2)
ctx.lineWidth = 2
ctx.strokeStyle = "rgba(255,255,255,0.95)"
ctx.stroke()
ctx.beginPath()
ctx.arc(x, hh/2, 6, 0, Math.PI*2)
ctx.lineWidth = 1
ctx.strokeStyle = "rgba(0,0,0,0.55)"
ctx.stroke()
}
}
MouseArea {
anchors.fill: parent
function apply(mx) {
root.a = root.clamp01(mx / width)
root.syncFromHSV()
}
onPressed: (e) => apply(e.x)
onPositionChanged: (e) => { if (pressed) apply(e.x) }
}
}
// 下方:色块 + Hex + 透明度百分比
RowLayout {
Layout.fillWidth: true
spacing: 10
// 预览色块(带棋盘底)
Rectangle {
width: 44
height: 44
radius: 8
border.width: 1
border.color: root.surfaceBorder
clip: true
Canvas {
anchors.fill: parent
onPaint: {
const ctx = getContext("2d")
const w = width, hh = height
ctx.clearRect(0,0,w,hh)
const s = 10
for (let y=0; y<hh; y+=s) {
for (let x=0; x<w; x+=s) {
const on = ((x/s + y/s) % 2) === 0
ctx.fillStyle = on ? "rgba(255,255,255,0.14)" : "rgba(0,0,0,0.0)"
ctx.fillRect(x,y,s,s)
}
}
}
}
Rectangle {
anchors.fill: parent
color: root.color
}
}
TextField {
id: hexField
Layout.fillWidth: true
text: "#FF7032D2"
placeholderText: "#AARRGGBB"
inputMethodHints: Qt.ImhPreferUppercase | Qt.ImhNoPredictiveText
// 用 palette而不是 color/selectionColor/selectedTextColor
palette.text: root.textPrimary
palette.placeholderText: root.textMuted
palette.highlight: root.highlightBg
palette.highlightedText: root.highlightText
palette.base: root.controlBg // 输入框底色(有的 style 会用它)
palette.buttonText: root.textPrimary
background: Rectangle {
radius: 8
color: root.controlBg
border.width: 1
border.color: root.controlBorder
}
onEditingFinished: {
const c = root.parseHex(text)
if (c) root.syncFromColor(c)
else text = root.colorToHexAARRGGBB(root.color)
}
}
Rectangle {
width: 54
height: 44
radius: 8
color: root.controlBg
border.width: 1
border.color: root.controlBorder
Label {
id: alphaPercent
anchors.centerIn: parent
text: "100%"
color: root.textPrimary
font.pixelSize: 12
}
}
}
// RGB / HSV 数值区(像参考图那样两行块)
GridLayout {
Layout.fillWidth: true
columns: 3
columnSpacing: 8
rowSpacing: 8
// ---- Row1: RGB ----
Label { text: "RGB"; color: root.textMuted; Layout.alignment: Qt.AlignVCenter }
SpinBox {
id: rField
from: 0; to: 255; editable: true
value: 112
Layout.fillWidth: true
onValueModified: root.applyRGB(value, gField.value, bField.value)
}
SpinBox {
id: gField
from: 0; to: 255; editable: true
value: 50
Layout.fillWidth: true
onValueModified: root.applyRGB(rField.value, value, bField.value)
}
Item { width: 1; height: 1 } // 占位,让下一行对齐
SpinBox {
id: bField
from: 0; to: 255; editable: true
value: 210
Layout.fillWidth: true
Layout.columnSpan: 2
onValueModified: root.applyRGB(rField.value, gField.value, value)
}
// ---- Row2: HSV ----
Label { text: "HSV"; color: root.textMuted; Layout.alignment: Qt.AlignVCenter }
SpinBox {
id: hField
from: 0; to: 360; editable: true
value: 263
Layout.fillWidth: true
onValueModified: root.applyHSV(value, sField.value, vField.value)
}
SpinBox {
id: sField
from: 0; to: 100; editable: true
value: 64
Layout.fillWidth: true
onValueModified: root.applyHSV(hField.value, value, vField.value)
}
Item { width: 1; height: 1 }
SpinBox {
id: vField
from: 0; to: 100; editable: true
value: 51
Layout.fillWidth: true
Layout.columnSpan: 2
onValueModified: root.applyHSV(hField.value, sField.value, value)
}
}
// 底部按钮(像窗口)
RowLayout {
Layout.fillWidth: true
spacing: 10
Item { Layout.fillWidth: true }
Button {
text: qsTr("Cancel")
onClicked: { root.visible = false; root.rejected() }
}
Button {
text: qsTr("OK")
highlighted: true
onClicked: { root.visible = false; root.accepted(root.color) }
}
}
}
}
// 每次 HSV 改变UI 同步
onHChanged: if (!_lock) syncFromHSV()
onSChanged: if (!_lock) syncFromHSV()
onVChanged: if (!_lock) syncFromHSV()
onAChanged: if (!_lock) syncFromHSV()
}

View File

@@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Controls.Material
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Dialogs
import "."
import TactileIPC 1.0
@@ -103,7 +104,7 @@ Rectangle {
ComboBox {
Layout.fillWidth: true
model: ["9600", "57600", "115200", "230400", "912600"]
model: ["9600", "57600", "115200", "230400", "921600"]
Component.onCompleted: {
const idx = model.indexOf(String(Backend.serial.baudRate))
if (idx >= 0)
@@ -208,6 +209,42 @@ Rectangle {
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("宽")
Layout.preferredWidth: 90
color: root.textColor
}
SpinBox {
Layout.fillWidth: true
from: 1
to: 20
value: Backend.sensorCol
enabled: Backend.serial.connected === false
onValueModified: Backend.sensorCol = value
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("高")
Layout.preferredWidth: 90
color: root.textColor
}
SpinBox {
Layout.fillWidth: true
from: 1
to: 20
value: Backend.sensorRow
enabled: Backend.serial.connected === false
onValueModified: Backend.sensorRow = value
}
}
RowLayout {
Layout.fillWidth: true
spacing: 12
@@ -372,6 +409,144 @@ Rectangle {
}
}
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 {
Layout.fillWidth: true
from: -999999
to: 999999
editable: true
value: Backend.rangeMin
onValueModified: Backend.rangeMin = value
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("最大值")
Layout.preferredWidth: 90
color: root.textColor
}
SpinBox {
Layout.fillWidth: true
from: -999999
to: 999999
editable: true
value: Backend.rangeMax
onValueModified: Backend.rangeMax = value
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("低色")
Layout.preferredWidth: 90
color: root.textColor
}
Rectangle {
width: 22
height: 22
radius: 4
color: Backend.colorLow
border.width: 1
border.color: Qt.rgba(0, 0, 0, 0.2)
Layout.alignment: Qt.AlignVCenter
MouseArea {
anchors.fill: parent
onClicked: {
lowColorDialog.openWith(Backend.colorLow)
}
}
}
Button {
text: root.tr("选择")
Layout.fillWidth: true
onClicked: {
lowColorDialog.openWith(Backend.colorLow)
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("中色")
Layout.preferredWidth: 90
color: root.textColor
}
Rectangle {
width: 22
height: 22
radius: 4
color: Backend.colorMid
border.width: 1
border.color: Qt.rgba(0, 0, 0, 0.2)
Layout.alignment: Qt.AlignVCenter
MouseArea {
anchors.fill: parent
onClicked: {
midColorDialog.openWith(Backend.colorMid)
}
}
}
Button {
text: root.tr("选择")
Layout.fillWidth: true
onClicked: {
midColorDialog.openWith(Backend.colorMid)
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: root.tr("高色")
Layout.preferredWidth: 90
color: root.textColor
}
Rectangle {
width: 22
height: 22
radius: 4
color: Backend.colorHigh
border.width: 1
border.color: Qt.rgba(0, 0, 0, 0.2)
Layout.alignment: Qt.AlignVCenter
MouseArea {
anchors.fill: parent
onClicked: {
highColorDialog.openWith(Backend.colorHigh)
}
}
}
Button {
text: root.tr("选择")
Layout.fillWidth: true
onClicked: {
highColorDialog.openWith(Backend.colorHigh)
}
}
}
}
CollapsiblePanel {
title: root.tr("显示控制")
expanded: true
@@ -399,7 +574,14 @@ Rectangle {
Button {
Layout.fillWidth: true
text: root.tr("导出数据")
onClicked: exportDlg.open()
onClicked: {
if (Backend.data.frameCount != 0) {
exportDlg.open()
}
else {
console.log("Backend.data.frameCount() === 0")
}
}
}
}
}
@@ -407,6 +589,25 @@ Rectangle {
Item { Layout.fillHeight: true }
}
}
ColorPickerDialog {
id: lowColorDialog
title: root.tr("选择低色")
onAccepted: Backend.colorLow = c
}
ColorPickerDialog {
id: midColorDialog
title: root.tr("选择中色")
onAccepted: Backend.colorMid = c
}
ColorPickerDialog {
id: highColorDialog
title: root.tr("选择高色")
onAccepted: Backend.colorHigh = c
}
SaveAsExportDialog {
id: exportDlg
/* onSaveTo: (folder, filename, format, method) => {

View File

@@ -7,9 +7,14 @@ Item {
id: root
property int minValue: 0
property int maxValue: 100
property color colorLow: Qt.rgba(0.10, 0.75, 1.00, 1.0)
property color colorMid: Qt.rgba(0.10, 0.95, 0.35, 1.0)
property color colorHigh: Qt.rgba(1.00, 0.22, 0.10, 1.0)
property int barWidth: 34
property int barRadius: 8
implicitWidth: 90
implicitHeight: 220
implicitWidth: barWidth + 48
implicitHeight: 240
ColumnLayout {
anchors.fill: parent
@@ -25,17 +30,16 @@ Item {
Rectangle {
Layout.alignment: Qt.AlignHCenter
Layout.fillHeight: true
width: 26
radius: 6
width: root.barWidth
radius: root.barRadius
border.width: 1
border.color: Qt.rgba(1, 1, 1, 0.18)
gradient: Gradient {
// must match shaders/dots.frag:dataColorRamp (high at top)
GradientStop { position: 0.00; color: Qt.rgba(1.00, 0.22, 0.10, 1.0) } // c3
GradientStop { position: 0.34; color: Qt.rgba(1.00, 0.92, 0.22, 1.0) } // c2
GradientStop { position: 0.67; color: Qt.rgba(0.10, 0.95, 0.35, 1.0) } // c1
GradientStop { position: 1.00; color: Qt.rgba(0.10, 0.75, 1.00, 1.0) } // c0
GradientStop { position: 0.00; color: root.colorHigh }
GradientStop { position: 0.50; color: root.colorMid }
GradientStop { position: 1.00; color: root.colorLow }
}
}
@@ -47,4 +51,3 @@ Item {
}
}
}

View File

@@ -92,8 +92,8 @@ Rectangle {
anchors.left: parent.left
anchors.leftMargin: 8
Image {
width: 18
height: 12
width: 16
height: 16
fillMode: Image.PreserveAspectFit
source: modelData.icon
}
@@ -107,10 +107,13 @@ Rectangle {
anchors.fill: parent
Row {
spacing: 8
anchors.left: parent.left
anchors.leftMargin: 10
anchors.rightMargin: 24
anchors.verticalCenter: parent.verticalCenter
Image {
width: 18
height: 12
width: 16
height: 16
fillMode: Image.PreserveAspectFit
source: langBox.model[langBox.currentIndex]
? langBox.model[langBox.currentIndex].icon

View File

@@ -0,0 +1,291 @@
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 importFormat: ""
property string importMethod: ""
signal importIn(url filename, string format, string method)
function open() {
}
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: {
}
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: {
}
background: Rectangle {
radius: 4
color: backBtn.hovered ? root.accentSoft : "transparent"
border.color: backBtn.hovered ? root.accent : root.border
}
}
ToolButton {
id: upBtn
text: "^"
font.family: root.uiFont
onClicked: {
}
background: Rectangle {
radius: 4
color: backBtn.hovered ? root.accentSoft : "transparent"
border.color: backBtn.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
}
}
}
}
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_(modeData.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
Lable {
// TODO table title
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: root.border
}
FolderListModel {
id: fileModel
folder: root.currentFolder
showDotAndDotDot: false
showDirs: true
showFiles: true
sortField: FolderListModel.Name
}
ListView {
id: fileList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: fileModel
delegate: ItemDelegate {
id: fileRow
width: ListView.view.width
onDoubleClicked: {
const isDir = fileModel.get(index, "fileIsDir")
if (isDir) {
root.currentFolder = normalizeFolder_(fileModel.get(index, "filePath"))
}
else {
// TODO import file
}
}
onClicked: {
fileList.currentIndex = index
}
background: Rectangle {
radius: 4
color: fileRow.hovered ? root.hoverBg : "transparent"
}
contentItem: RowLayout {
spacing: 8
Rectangle {
width+:
}
}
}
}
}
}
}
}
}

View File

@@ -22,177 +22,205 @@ Rectangle {
Material.accent: Material.Green
Material.primary: Material.Green
ColumnLayout {
ScrollView {
id: scrollView
anchors.fill: parent
anchors.margins: 12
spacing: 12
ScrollBar.vertical.policy: ScrollBar.AlwaysOff
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
contentWidth: scrollView.availableWidth
contentHeight: contentLayout.implicitHeight + 24
LiveTrendCard {
id: card
Layout.fillWidth: true
Layout.preferredHeight: 180
title: root.tr("Payload Sum")
ColumnLayout {
id: contentLayout
x: 12
y: 12
width: scrollView.availableWidth - 24
spacing: 12
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
LiveTrendCard {
id: card
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]
title: root.tr("Payload Sum")
onPaint: {
const ctx = getContext("2d")
const w = width
const h = height
ctx.clearRect(0, 0, w, h)
Connections {
target: Backend.data
function onMetricsChanged() {
if (Backend.data.frameCount > 0)
card.plot.append(Backend.data.metricSum)
}
}
}
ctx.strokeStyle = "#D8EAD9"
ctx.lineWidth = 1
for (let i = 1; i < 5; i++) {
const y = (h / 5) * i
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()
ctx.moveTo(0, y)
ctx.lineTo(w, y)
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()
}
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)
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 }
}
}
ctx.stroke()
}
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
Component.onCompleted: requestPaint()
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
}
}
}
CollapsiblePanel {
title: root.tr("Legend")
expanded: true
Layout.fillWidth: true
Legend {
Layout.alignment: Qt.AlignHCenter
Layout.preferredHeight: 200
barWidth: 36
minValue: Backend.rangeMin
maxValue: Backend.rangeMax
colorLow: Backend.colorLow
colorMid: Backend.colorMid
colorHigh: Backend.colorHigh
}
}
Item { Layout.fillHeight: true }
}
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

@@ -11,6 +11,7 @@
<file>qml/content/LeftPanel.qml</file>
<file>qml/content/RightPanel.qml</file>
<file>qml/content/CollapsiblePanel.qml</file>
<file>qml/content/ColorPickerDialog.qml</file>
<file>shaders/dots.frag</file>
<file>shaders/dots.vert</file>
<file>shaders/bg.frag</file>

View File

@@ -6,6 +6,9 @@ out vec4 FragColor;
uniform float uMinV;
uniform float uMaxV;
uniform vec3 uColorLow;
uniform vec3 uColorMid;
uniform vec3 uColorHigh;
uniform sampler2D uDotTex;
uniform int uHasData; // 0 = no data, 1 = has data
uniform vec3 uCameraPos;
@@ -18,14 +21,8 @@ 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);
if (t < 0.5) return mix(uColorLow, uColorMid, t / 0.5);
return mix(uColorMid, uColorHigh, (t - 0.5) / 0.5);
}
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
@@ -117,9 +114,8 @@ void main() {
float value01 = clamp((vValue - uMinV) / max(1e-6, (uMaxV - uMinV)), 0.0, 1.0);
vec3 dataCol = dataColorRamp(value01);
// bool hasData = (uHasData != 0);
// vec3 baseColor = hasData ? dataCol : metalBase;
vec3 baseColor = metalBase;
bool hasData = (uHasData != 0);
vec3 baseColor = hasData ? dataCol : metalBase;
// dataViz: flat/unlit, no lighting modulation (keep pure baseColor)
if (uRenderMode == 1) {

View File

@@ -20,6 +20,8 @@ AppBackend::AppBackend(QObject* parent)
m_data->clear();
emit connectedChanged();
});
connect(this, &AppBackend::sensorRowChanged, m_serial, &SerialBackend::setSensorHeight);
connect(this, &AppBackend::sensorColChanged, m_serial, &SerialBackend::setSensorWidth);
}
bool AppBackend::connected() const {
@@ -47,4 +49,67 @@ void AppBackend::setShowGrid(bool on) {
m_showGrid = on;
emit showGridChanged(on);
}
}
void AppBackend::setSensorCol(int c) {
if (m_serial->connected()) {
return;
}
m_sensorCol = c;
qInfo() << "sensorColChanged: " << c;
emit sensorColChanged(c);
}
void AppBackend::setSensorRow(int r) {
if (m_serial->connected()) {
return;
}
m_sensorRow = r;
qInfo() << "sensorRowChanged: " << r;
emit sensorRowChanged(r);
}
void AppBackend::setRangeMin(int v) {
if (m_rangeMin == v)
return;
m_rangeMin = v;
if (m_rangeMin > m_rangeMax) {
m_rangeMax = m_rangeMin;
emit rangeMaxChanged(m_rangeMax);
}
emit rangeMinChanged(m_rangeMin);
emit rangeChanged(m_rangeMin, m_rangeMax);
}
void AppBackend::setRangeMax(int v) {
if (m_rangeMax == v)
return;
m_rangeMax = v;
if (m_rangeMax < m_rangeMin) {
m_rangeMin = m_rangeMax;
emit rangeMinChanged(m_rangeMin);
}
emit rangeMaxChanged(m_rangeMax);
emit rangeChanged(m_rangeMin, m_rangeMax);
}
void AppBackend::setColorLow(const QColor& color) {
if (m_colorLow == color)
return;
m_colorLow = color;
emit colorLowChanged(m_colorLow);
}
void AppBackend::setColorMid(const QColor& color) {
if (m_colorMid == color)
return;
m_colorMid = color;
emit colorMidChanged(m_colorMid);
}
void AppBackend::setColorHigh(const QColor& color) {
if (m_colorHigh == color)
return;
m_colorHigh = color;
emit colorHighChanged(m_colorHigh);
}

View File

@@ -6,6 +6,7 @@
#define TACTILEIPC3D_BACKEND_H
#include <QObject>
#include <QString>
#include <QColor>
#include <qtmetamacros.h>
#include "data_backend.h"
@@ -19,6 +20,13 @@ class AppBackend : public QObject {
Q_PROPERTY(bool showGrid READ showGrid WRITE setShowGrid NOTIFY showGridChanged);
Q_PROPERTY(SerialBackend* serial READ serial CONSTANT)
Q_PROPERTY(DataBackend* data READ data CONSTANT)
Q_PROPERTY(int sensorCol READ sensorCol WRITE setSensorCol NOTIFY sensorColChanged);
Q_PROPERTY(int sensorRow READ sensorRow WRITE setSensorRow NOTIFY sensorRowChanged);
Q_PROPERTY(int rangeMin READ rangeMin WRITE setRangeMin NOTIFY rangeMinChanged);
Q_PROPERTY(int rangeMax READ rangeMax WRITE setRangeMax NOTIFY rangeMaxChanged);
Q_PROPERTY(QColor colorLow READ colorLow WRITE setColorLow NOTIFY colorLowChanged);
Q_PROPERTY(QColor colorMid READ colorMid WRITE setColorMid NOTIFY colorMidChanged);
Q_PROPERTY(QColor colorHigh READ colorHigh WRITE setColorHigh NOTIFY colorHighChanged);
public:
explicit AppBackend(QObject* parent=nullptr);
@@ -35,12 +43,34 @@ public:
bool showGrid() const { return m_showGrid; }
void setShowGrid(bool on);
int sensorCol() const { qInfo() << "col:" << m_sensorCol; return m_sensorCol; }
int sensorRow() const { qInfo() << "row:" << m_sensorRow; return m_sensorRow; }
void setSensorRow(int r);
void setSensorCol(int c);
int rangeMin() const { return m_rangeMin; }
int rangeMax() const { return m_rangeMax; }
void setRangeMin(int v);
void setRangeMax(int v);
QColor colorLow() const { return m_colorLow; }
QColor colorMid() const { return m_colorMid; }
QColor colorHigh() const { return m_colorHigh; }
void setColorLow(const QColor& color);
void setColorMid(const QColor& color);
void setColorHigh(const QColor& color);
signals:
void lightModeChanged();
void languageChanged();
void connectedChanged();
void showGridChanged(bool on);
void sensorColChanged(int c);
void sensorRowChanged(int r);
void rangeMinChanged(int v);
void rangeMaxChanged(int v);
void rangeChanged(int minV, int maxV);
void colorLowChanged(const QColor& color);
void colorMidChanged(const QColor& color);
void colorHighChanged(const QColor& color);
private:
SerialBackend* m_serial = nullptr;
DataBackend* m_data = nullptr;
@@ -48,6 +78,13 @@ private:
QString m_language = QStringLiteral("zh_CN");
bool m_showGrid = true;
int m_sensorRow = 12;
int m_sensorCol = 7;
int m_rangeMin = 0;
int m_rangeMax = 1000;
QColor m_colorLow = QColor::fromRgbF(0.10, 0.75, 1.00);
QColor m_colorMid = QColor::fromRgbF(0.10, 0.95, 0.35);
QColor m_colorHigh = QColor::fromRgbF(1.00, 0.22, 0.10);
};
#endif //TACTILEIPC3D_BACKEND_H

View File

@@ -23,7 +23,7 @@ DataBackend::DataBackend(QObject* parent)
emitFrame_(m_frames[m_playbackIndex], cb);
m_playbackIndex++;
});
seedDebugFrames_();
// seedDebugFrames_();
}
void DataBackend::ingestFrame(const DataFrame& frame) {
@@ -56,6 +56,14 @@ bool DataBackend::exportCsv(const QString& path) const {
return true;
}
bool DataBackend::exportXlsx(const QString& path) const {
/* QFile file(path);
if (!file.open(QIODevice::WriteOnly))
return false;
*/
return buildXlsx_(path);
}
bool DataBackend::importJson(const QString& path) {
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
@@ -70,6 +78,57 @@ bool DataBackend::importCsv(const QString& path) {
return loadCsv_(file.readAll());
}
bool DataBackend::importXlsx(const QString& path) {
QXlsx::Document doc(path);
if (!doc.isLoadPackage()) {
qCritical() << "failed to open file: " << path;
return false;
}
stopPlayback();
clear();
doc.selectSheet(1);
QXlsx::CellRange range = doc.dimension();
int firstRow = range.firstRow();
int lastRow = range.lastRow();
int firstCol = range.firstColumn();
int lastCol = range.lastColumn();
if (firstRow == 0 && lastRow == 0 && firstCol == 0 && lastCol == 0) {
return false;
}
int row_count = lastRow - firstRow + 1;
int col_count = lastCol - firstCol + 1;
// TODO 完善xlsx数据导入
struct SpanInfo {
int row = 0;
int column = 0;
int rowSpan = 1;
int colSpan = 1;
};
QVector<QVector<QVariant>> cells;
QVector<SpanInfo> spans;
for (int r = 0; r < row_count; ++r) {
cells.resize(col_count);
for (int c = 0; c < col_count; ++c) {
int excel_row = firstRow + r;
int excel_col = firstCol + c;
std::shared_ptr<QXlsx::Cell> cell_obj = doc.cellAt(excel_row, excel_col);
QVariant v;
if (cell_obj) {
v = cell_obj->readValue();
}
else {
v = doc.read(excel_row, excel_col);
}
cells[r][c] = v;
}
}
}
void DataBackend::startPlayback(int intervalMs) {
if (m_frames.isEmpty())
return;
@@ -273,6 +332,47 @@ QByteArray DataBackend::buildCsv_() const {
return out;
}
bool DataBackend::buildXlsx_(const QString& path) const {
QXlsx::Document xlsx;
int current_sheet_index = 1;
int current_sheet_row_start = 1;
int col_count = m_frames.at(0).data.size();
auto ensure_sheet_for_row = [&](int row){
if (m_frames.size() <= 0) {
if (xlsx.currentWorksheet() == nullptr)
xlsx.addSheet("Sheet1");
return;
}
if (xlsx.currentWorksheet() == nullptr) {
xlsx.addSheet(QStringLiteral("Sheet%1").arg(current_sheet_index));
current_sheet_row_start = 1;
return;
}
if (row - current_sheet_row_start >= m_frames.size()) {
++current_sheet_index;
xlsx.addSheet(QStringLiteral("Sheet%1").arg(current_sheet_index));
current_sheet_row_start = row;
}
};
for (size_t row = 1; row <= m_frames.size(); row++) {
ensure_sheet_for_row(row);
xlsx.write(1, 1, m_frames.at(row - 1).pts);
int col_index = 2;
for (auto data : m_frames.at(row - 1).data) {
xlsx.write(row, col_index, data);
col_index++;
}
}
if (!xlsx.saveAs(path)) {
qCritical() << "failed to save file: " << path;
return false;
}
return true;
}
void DataBackend::seedDebugFrames_() {
if (!m_frames.isEmpty())
return;

View File

@@ -9,6 +9,8 @@
#include <QSerialPort>
#include <QSerialPortInfo>
#include <qtmetamacros.h>
#include "xlsxdocument.h"
#include "xlsxformat.h"
#include "data_frame.h"
class DataBackend : public QObject {
@@ -42,13 +44,14 @@ public:
Q_INVOKABLE void clear();
Q_INVOKABLE bool exportJson(const QString& path) const;
Q_INVOKABLE bool exportCsv(const QString& path) const;
Q_INVOKABLE bool exportXlsx(const QString& path) const;
Q_INVOKABLE bool importJson(const QString& path);
Q_INVOKABLE bool importCsv(const QString& path);
Q_INVOKABLE bool importXlsx(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();
@@ -59,6 +62,7 @@ private:
bool loadCsv_(const QByteArray& data);
QByteArray buildJson_() const;
QByteArray buildCsv_() const;
bool buildXlsx_(const QString& path) const;
void seedDebugFrames_();

49
src/globalhelper.h Normal file
View File

@@ -0,0 +1,49 @@
//
// Created by Lenn on 2026/1/20.
//
#ifndef TACTILEIPC3D_GLOBALHELPER_H
#define TACTILEIPC3D_GLOBALHELPER_H
#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
class GlobalHelper {
public:
const std::string fuck = "fuck you lsp!!!";
static GlobalHelper* Instance() {
static GlobalHelper ins;
return &ins;
}
static void transToMultiMatrix(const cv::Mat& raw_data, float min_threshold,
float max_range, cv::Size display_res, cv::Mat& out_image) {
CV_Assert(raw_data.type() == CV_32F);
cv::Mat raw = raw_data.clone();
raw.setTo(0.0f, raw < min_threshold);
double maxVal = 0.0;
cv::minMaxLoc(raw, nullptr, &maxVal);
if (maxVal > 0.0) {
cv::GaussianBlur(raw, raw, cv::Size(3, 3), 0.8, 0.8, cv::BORDER_DEFAULT);
}
cv::Mat norm_data = raw / max_range;
cv::min(norm_data, 1.0f, norm_data);
cv::max(norm_data, 0.0f, norm_data);
cv::pow(norm_data, 0.7, norm_data);
cv::Mat smoothed;
cv::resize(norm_data, smoothed, display_res, 0.0, 0.0, cv::INTER_CUBIC);
cv::GaussianBlur(smoothed, smoothed, cv::Size(31, 31), 0.0, 0.0, cv::BORDER_DEFAULT);
cv::transpose(smoothed, out_image);
}
private:
GlobalHelper() {}
};
#endif //TACTILEIPC3D_GLOBALHELPER_H

View File

@@ -8,7 +8,6 @@
#include <QtMath>
#include <QFile>
#include <QDebug>
#include <GL/gl.h>
#include <qevent.h>
#include <qlogging.h>
#include <qminmax.h>
@@ -41,6 +40,10 @@ static void matIdentity(float m[16]) {
m[0] = m[5] = m[10] = m[15] = 1;
}
static QVector3D toColorVec(const QColor& color) {
return QVector3D(color.redF(), color.greenF(), color.blueF());
}
GLWidget::GLWidget(QWidget *parent)
: QOpenGLWidget(parent) {
setMinimumSize(640, 480);
@@ -191,6 +194,30 @@ void GLWidget::setShowBg(bool on = true) {
update();
}
void GLWidget::setColorLow(const QColor& color) {
const QVector3D next = toColorVec(color);
if (m_colorLow == next)
return;
m_colorLow = next;
update();
}
void GLWidget::setColorMid(const QColor& color) {
const QVector3D next = toColorVec(color);
if (m_colorMid == next)
return;
m_colorMid = next;
update();
}
void GLWidget::setColorHigh(const QColor& color) {
const QVector3D next = toColorVec(color);
if (m_colorHigh == next)
return;
m_colorHigh = next;
update();
}
void GLWidget::initializeGL() {
initializeOpenGLFunctions();
@@ -211,6 +238,14 @@ void GLWidget::initializeGL() {
matIdentity(m_proj);
}
void GLWidget::initGeometry_() {
initDotTexture_();
initBackgroundGeometry_();
initPanelGeometry_();
initDotGeometry_();
initRoomGeometry_();
}
void GLWidget::resizeGL(int w, int h) {
glViewport(0, 0, w, h);
}
@@ -293,8 +328,11 @@ void GLWidget::paintGL() {
// uMinV/uMaxV: 传感值范围,用于 fragment shader 把 value 映射成颜色
m_dotsProg->setUniformValue("uMinV", float(m_min));
m_dotsProg->setUniformValue("uMaxV", float(m_max));
m_dotsProg->setUniformValue("uColorLow", m_colorLow);
m_dotsProg->setUniformValue("uColorMid", m_colorMid);
m_dotsProg->setUniformValue("uColorHigh", m_colorHigh);
const int hasData = (m_hasData.load() || m_dotTex == 0) ? 1 : 0;
m_dotsProg->setUniformValue("uHasData", 0);
m_dotsProg->setUniformValue("uHasData", hasData);
m_dotsProg->setUniformValue("uCameraPos", m_cameraPos);
m_dotsProg->setUniformValue("uDotTex", 0);
if (m_dotTex) {
@@ -427,6 +465,7 @@ void GLWidget::mousePressEvent(QMouseEvent *event) {
if (event->button() == Qt::LeftButton) {
QVector3D world;
const int index = pickDotIndex_(event->pos(), &world);
qInfo() << "clicked index: " << index;
if (index >= 0) {
float value = 0.0f;
int row = 0;
@@ -1098,3 +1137,17 @@ void GLWidget::setShowGrid(bool on) {
m_showGrid = on;
update();
}
void GLWidget::setRow(int row) {
row = qMax(0, row);
if (m_rows == row)
return;
setSpec(row, m_cols, m_pitch, m_dotRadius);
}
void GLWidget::setCol(int col) {
col = qMax(0, col);
if (m_cols == col)
return;
setSpec(m_rows, col, m_pitch, m_dotRadius);
}

View File

@@ -14,6 +14,7 @@
#include <QMatrix4x4>
#include <QVector3D>
#include <QString>
#include <QColor>
#include <atomic>
#include <qtmetamacros.h>
#include <qvectornd.h>
@@ -26,6 +27,8 @@ struct Ray {
class GLWidget : public QOpenGLWidget, protected QOpenGLFunctions_3_3_Core {
Q_OBJECT
Q_PROPERTY(float yaw READ yaw WRITE setYaw NOTIFY yawChanged)
Q_PROPERTY(int row READ row WRITE setRow)
Q_PROPERTY(int col READ col WRITE setCol)
// Q_PROPERTY(bool showGrid READ showGrid WRITE setShowGrid NOTIFY showGridChanged)
public:
enum RenderMode {
@@ -59,7 +62,8 @@ public:
bool showGrid() const { return m_showGrid; }
int row() const { return m_rows; }
int col() const { return m_cols; }
public slots:
// 值域范围,用于 shader 里把 value 映射到颜色(绿->红)
@@ -70,6 +74,11 @@ public slots:
void setLightMode(bool on);
void setShowBg(bool on);
void setShowGrid(bool on);
void setCol(int col);
void setRow(int row);
void setColorLow(const QColor& color);
void setColorMid(const QColor& color);
void setColorHigh(const QColor& color);
signals:
void yawChanged();
@@ -85,6 +94,7 @@ protected:
void wheelEvent(QWheelEvent *event) override;
private:
void initGeometry_();
void initPanelGeometry_();
void initDotGeometry_();
void initBackgroundGeometry_();
@@ -154,6 +164,9 @@ private:
unsigned int m_bgVbo = 0;
bool m_lightMode = true;
bool m_showBg = true;
QVector3D m_colorLow{0.10f, 0.75f, 1.00f};
QVector3D m_colorMid{0.10f, 0.95f, 0.35f};
QVector3D m_colorHigh{1.00f, 0.22f, 0.10f};
// MVP = Projection * View * Model。
// 这里 panel/dots 顶点基本已经是“世界坐标”,所以我们用 proj*view 即可model 先省略)。

View File

@@ -10,16 +10,11 @@
#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) {
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>();
@@ -29,27 +24,27 @@ SerialBackend::SerialBackend(QObject* parent)
m_sendWorker = new SerialSendWorker();
m_sendWorker->moveToThread(&m_sendThread);
connect(m_sendWorker, &SerialSendWorker::bytesReceived, this, [this](const QByteArray& data) {
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);
});
m_readThread.enqueueBytes(data); });
connect(m_sendWorker, &SerialSendWorker::requestBuilt, this, &SerialBackend::requestBuilt);
connect(m_sendWorker, &SerialSendWorker::writeFailed, this, [](const QString& error) {
connect(m_sendWorker, &SerialSendWorker::writeFailed, this, [](const QString &error)
{
if (!error.isEmpty())
qWarning().noquote() << "Serial write failed:" << error;
});
qWarning().noquote() << "Serial write failed:" << error; });
connect(&m_readThread, &SerialReadThread::parseError, this, [](const QString& 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) {
qWarning().noquote() << "Serial packet invalid:" << error; });
connect(&m_decodeThread, &SerialDecodeThread::decodeError, this, [](const QString &error)
{
if (!error.isEmpty())
qWarning().noquote() << "Serial decode failed:" << error;
});
qWarning().noquote() << "Serial decode failed:" << error; });
connect(&m_decodeThread, &SerialDecodeThread::frameAvailable, this, &SerialBackend::drainFrames_);
m_sendThread.start();
@@ -61,21 +56,24 @@ SerialBackend::SerialBackend(QObject* parent)
refreshPorts();
}
SerialBackend::~SerialBackend() {
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, [worker = m_sendWorker]()
{ worker->closeTransport(); }, Qt::BlockingQueuedConnection);
}
if (m_sendWorker && m_sendThread.isRunning()) {
if (m_sendWorker && m_sendThread.isRunning())
{
QMetaObject::invokeMethod(m_sendWorker, &QObject::deleteLater, Qt::QueuedConnection);
}
if (m_sendThread.isRunning()) {
if (m_sendThread.isRunning())
{
m_sendThread.quit();
m_sendThread.wait();
}
@@ -83,15 +81,18 @@ SerialBackend::~SerialBackend() {
m_sendWorker = nullptr;
}
QString SerialBackend::mode() const {
QString SerialBackend::mode() const
{
return (m_config.mode == DeviceMode::Slave) ? QStringLiteral("slave") : QStringLiteral("master");
}
QString SerialBackend::sensorGrid() const {
QString SerialBackend::sensorGrid() const
{
return QStringLiteral("%1x%2").arg(m_spec.rows).arg(m_spec.cols);
}
void SerialBackend::setPortName(const QString& name) {
void SerialBackend::setPortName(const QString &name)
{
if (m_config.portName == name)
return;
m_config.portName = name;
@@ -99,7 +100,8 @@ void SerialBackend::setPortName(const QString& name) {
emit portNameChanged();
}
void SerialBackend::setBaudRate(int rate) {
void SerialBackend::setBaudRate(int rate)
{
if (m_config.baudRate == rate)
return;
m_config.baudRate = rate;
@@ -107,7 +109,8 @@ void SerialBackend::setBaudRate(int rate) {
emit baudRateChanged();
}
void SerialBackend::setPollIntervalMs(int intervalMs) {
void SerialBackend::setPollIntervalMs(int intervalMs)
{
intervalMs = qMax(1, intervalMs);
if (m_config.pollIntervalMs == intervalMs)
return;
@@ -116,7 +119,8 @@ void SerialBackend::setPollIntervalMs(int intervalMs) {
emit pollIntervalMsChanged();
}
void SerialBackend::setDeviceAddress(int address) {
void SerialBackend::setDeviceAddress(int address)
{
const int capped = qBound(0, address, 255);
if (m_config.deviceAddress == static_cast<quint8>(capped))
return;
@@ -125,7 +129,8 @@ void SerialBackend::setDeviceAddress(int address) {
emit deviceAddressChanged();
}
void SerialBackend::setMode(const QString& mode) {
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)
@@ -135,7 +140,8 @@ void SerialBackend::setMode(const QString& mode) {
emit modeChanged();
}
void SerialBackend::setRequestFunction(int func) {
void SerialBackend::setRequestFunction(int func)
{
const int capped = qBound(0, func, 255);
if (m_request.functionCode == static_cast<quint8>(capped))
return;
@@ -144,7 +150,8 @@ void SerialBackend::setRequestFunction(int func) {
emit requestFunctionChanged();
}
void SerialBackend::setRequestStartAddress(int addr) {
void SerialBackend::setRequestStartAddress(int addr)
{
const quint32 capped = static_cast<quint32>(qMax(0, addr));
if (m_request.startAddress == capped)
return;
@@ -153,7 +160,8 @@ void SerialBackend::setRequestStartAddress(int addr) {
emit requestStartAddressChanged();
}
void SerialBackend::setRequestLength(int len) {
void SerialBackend::setRequestLength(int len)
{
const int capped = qBound(0, len, 65535);
if (m_request.dataLength == static_cast<quint16>(capped))
return;
@@ -162,26 +170,30 @@ void SerialBackend::setRequestLength(int len) {
emit requestLengthChanged();
}
void SerialBackend::setProtocol(const QString& name) {
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) {
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) {
if (!nextModel.isEmpty() && m_spec.model != nextModel)
{
m_spec.model = nextModel;
emit sensorModelChanged();
changed = true;
}
if (m_spec.rows != nextRows || m_spec.cols != nextCols) {
if (m_spec.rows != nextRows || m_spec.cols != nextCols)
{
m_spec.rows = nextRows;
m_spec.cols = nextCols;
emit sensorGridChanged();
@@ -192,34 +204,38 @@ void SerialBackend::applySensorSpec(const QString& model, int rows, int cols) {
return;
}
void SerialBackend::setTransport(std::unique_ptr<ISerialTransport> transport) {
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);
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, transport = std::move(transport)]() mutable
{ worker->setTransport(std::move(transport)); }, Qt::BlockingQueuedConnection);
}
void SerialBackend::refreshPorts() {
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) {
for (auto item : device_found)
{
m_availablePorts.append(item.portName());
}
if (m_config.portName.isEmpty() && !m_availablePorts.isEmpty()) {
if (m_config.portName.isEmpty() && !m_availablePorts.isEmpty())
{
m_config.portName = m_availablePorts.first();
emit portNameChanged();
}
emit availablePortsChanged();
}
bool SerialBackend::open() {
bool SerialBackend::open()
{
if (m_connected)
return true;
@@ -227,12 +243,13 @@ bool SerialBackend::open() {
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 (m_sendWorker)
{
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, config = m_config, &ok, &error]()
{ ok = worker->openTransport(config, &error); }, Qt::BlockingQueuedConnection);
}
if (!ok) {
if (!ok)
{
qWarning().noquote() << "Serial open failed:" << error;
return false;
}
@@ -243,34 +260,49 @@ bool SerialBackend::open() {
return true;
}
void SerialBackend::close() {
void SerialBackend::close()
{
if (!m_connected)
return;
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, [worker = m_sendWorker]()
{ worker->closeTransport(); }, Qt::BlockingQueuedConnection);
}
stopPipeline_();
m_connected = false;
emit connectedChanged();
}
void SerialBackend::requestOnce() {
void SerialBackend::requestOnce()
{
if (!m_sendWorker)
return;
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker]() {
worker->requestOnce();
}, Qt::QueuedConnection);
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker]()
{ worker->requestOnce(); }, Qt::QueuedConnection);
}
void SerialBackend::feedBytes(const QByteArray& data) {
void SerialBackend::feedBytes(const QByteArray &data)
{
if (!m_readThread.isRunning())
startPipeline_();
m_readThread.enqueueBytes(data);
}
void SerialBackend::drainFrames_() {
void SerialBackend::setSensorWidth(int w)
{
m_spec.cols = w;
syncSendConfig_();
}
void SerialBackend::setSensorHeight(int h)
{
m_spec.rows = h;
syncSendConfig_();
}
void SerialBackend::drainFrames_()
{
if (!m_frameCallback)
return;
DataFrame frame;
@@ -278,7 +310,8 @@ void SerialBackend::drainFrames_() {
m_frameCallback(frame);
}
void SerialBackend::startPipeline_() {
void SerialBackend::startPipeline_()
{
if (m_readThread.isRunning() || m_decodeThread.isRunning())
stopPipeline_();
m_packetQueue.reset();
@@ -292,12 +325,15 @@ void SerialBackend::startPipeline_() {
m_decodeThread.start();
}
void SerialBackend::stopPipeline_() {
if (m_readThread.isRunning()) {
void SerialBackend::stopPipeline_()
{
if (m_readThread.isRunning())
{
m_readThread.stop();
m_readThread.wait();
}
if (m_decodeThread.isRunning()) {
if (m_decodeThread.isRunning())
{
m_decodeThread.stop();
m_decodeThread.wait();
}
@@ -306,53 +342,60 @@ void SerialBackend::stopPipeline_() {
m_readThread.clear();
}
void SerialBackend::updateProtocolBindings_() {
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) {
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) {
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) {
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);
if (m_sendWorker)
{
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, requestFunc]() mutable
{ worker->setBuildRequestFunc(std::move(requestFunc)); }, Qt::QueuedConnection);
}
}
void SerialBackend::syncSendConfig_() {
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);
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, config]()
{ worker->setConfig(config); }, Qt::QueuedConnection);
}
void SerialBackend::syncSendRequest_() {
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);
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, request]()
{ worker->setRequest(request); }, Qt::QueuedConnection);
}

View File

@@ -69,6 +69,13 @@ public:
Q_INVOKABLE void requestOnce();
Q_INVOKABLE void feedBytes(const QByteArray& data);
int sensorWidth() const { return m_spec.cols; }
int sensorHeight() const { return m_spec.rows; }
public slots:
void setSensorWidth(int w);
void setSensorHeight(int h);
signals:
void portNameChanged();
void baudRateChanged();

View File

@@ -32,8 +32,8 @@ struct SensorRequest {
struct SensorSpec {
QString model;
QString version;
int rows = 0;
int cols = 0;
int rows = 12;
int cols = 7;
float pitch = 0.0f;
float dotRadius = 0.0f;
float rangeMin = 0.0f;