Compare commits
11 Commits
1960e6a5b9
...
0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c9495680d6 | |||
| 4882dc1a67 | |||
| bc9f2824ed | |||
|
|
54d285cbc8 | ||
| 59564fd312 | |||
| 053e247380 | |||
| 02f5368b89 | |||
| 354552dc88 | |||
| f700dd360e | |||
| d1aabeb8e3 | |||
| e8dea24a16 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,7 +11,7 @@ TactileIpc3D_autogen/
|
||||
*.ninja
|
||||
*.ninja_deps
|
||||
*.ninja_log
|
||||
|
||||
OpenCV/
|
||||
# Qt generated files
|
||||
*.moc
|
||||
moc_*.cpp
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "3rdpart/QXlsx"]
|
||||
path = 3rdpart/QXlsx
|
||||
url = https://github.com/QtExcel/QXlsx.git
|
||||
1
3rdpart/QXlsx
Submodule
1
3rdpart/QXlsx
Submodule
Submodule 3rdpart/QXlsx added at 9f545935a1
@@ -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。
|
||||
|
||||
@@ -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” 的淡淡雾化效果
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
66
README.md
66
README.md
@@ -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 Trend(Payload 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
1
VERSION.txt
Normal file
@@ -0,0 +1 @@
|
||||
1.0.0
|
||||
@@ -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-05:CollapsiblePanel 组件改为跟随 `backend.lightMode` 切换暗色主题配色。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -171,6 +171,10 @@
|
||||
<source>Live Trend</source>
|
||||
<translation>实时趋势</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Legend</source>
|
||||
<translation>图例</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Metrics</source>
|
||||
<translation>指标</translation>
|
||||
|
||||
52
installer/TactileIpc3D.iss
Normal file
52
installer/TactileIpc3D.iss
Normal 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
|
||||
6
installer/assets/README.txt
Normal file
6
installer/assets/README.txt
Normal 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.
|
||||
90
main.cpp
90
main.cpp
@@ -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);
|
||||
|
||||
@@ -6,7 +6,7 @@ import TactileIPC 1.0
|
||||
|
||||
Item {
|
||||
id: root
|
||||
width: 350
|
||||
implicitWidth: 350
|
||||
|
||||
property alias title: titleText.text
|
||||
property bool expanded: true
|
||||
|
||||
616
qml/content/ColorPickerDialog.qml
Normal file
616
qml/content/ColorPickerDialog.qml
Normal 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()
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
291
qml/content/OpenFileDialog.qml
Normal file
291
qml/content/OpenFileDialog.qml
Normal 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+:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
@@ -48,3 +50,66 @@ 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
49
src/globalhelper.h
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 先省略)。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user