Compare commits

7 Commits

Author SHA1 Message Date
c9495680d6 Merge branch 'main' of http://gitea.huangyanjie.com/lenn/tactileipc3d 2026-01-20 20:00:49 +08:00
4882dc1a67 update gitignore 2026-01-20 20:00:16 +08:00
bc9f2824ed 点阵完成,加入opencv 2026-01-20 19:55:56 +08:00
Lenn
54d285cbc8 fix:baud error 2026-01-20 11:13:31 +08:00
59564fd312 feat:write data pipeline into OpenGL Widget 2026-01-16 09:19:55 +08:00
053e247380 update README 2026-01-15 16:36:42 +08:00
02f5368b89 delete AUTO_PANEL_SIZING.md BACKGROUND_GRID.md 2026-01-15 16:20:34 +08:00
16 changed files with 930 additions and 457 deletions

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -9,9 +9,6 @@ set(CMAKE_AUTOUIC ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
add_subdirectory(3rdpart/QXlsx/QXlsx)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src)
find_package(Qt6 COMPONENTS find_package(Qt6 COMPONENTS
Core Core
@@ -28,6 +25,10 @@ find_package(Qt6 COMPONENTS
LinguistTools LinguistTools
) )
set(QT_VERSION_MAJOR 6)
add_subdirectory(3rdpart/QXlsx/QXlsx)
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src)
qt_standard_project_setup() qt_standard_project_setup()
add_executable(TactileIpc3D add_executable(TactileIpc3D
@@ -62,8 +63,10 @@ add_executable(TactileIpc3D
src/ringbuffer.cpp src/ringbuffer.cpp
src/sparkline_plotitem.h src/sparkline_plotitem.h
src/sparkling_plotitem.cpp src/sparkling_plotitem.cpp
src/globalhelper.h
src/globalhelper.h
) )
target_link_libraries(TactileIpc3D target_link_libraries(TactileIpc3D PRIVATE
Qt6::Core Qt6::Core
Qt6::Gui Qt6::Gui
Qt6::Widgets Qt6::Widgets
@@ -76,7 +79,9 @@ target_link_libraries(TactileIpc3D
Qt6::QuickDialogs2 Qt6::QuickDialogs2
QXlsx::QXlsx QXlsx::QXlsx
) )
target_include_directories(TactileIpc3D PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/3rdpart/OpenCV/include
)
set(TS_FILES set(TS_FILES
${CMAKE_CURRENT_SOURCE_DIR}/i18n/app_zh_CN.ts ${CMAKE_CURRENT_SOURCE_DIR}/i18n/app_zh_CN.ts
${CMAKE_CURRENT_SOURCE_DIR}/i18n/app_en_US.ts ${CMAKE_CURRENT_SOURCE_DIR}/i18n/app_en_US.ts
@@ -109,31 +114,17 @@ qt_add_resources(TactileIpc3D i18n_resources
FILES ${QM_FILES} FILES ${QM_FILES}
) )
#if (WIN32 AND NOT DEFINED CMAKE_TOOLCHAIN_FILE) set(runtime_out_dir "${CMAKE_BINARY_DIR}/out")
# set(DEBUG_SUFFIX) set_target_properties(TactileIpc3D PROPERTIES
# if (MSVC AND CMAKE_BUILD_TYPE MATCHES "Debug") RUNTIME_OUTPUT_DIRECTORY "${runtime_out_dir}"
# set(DEBUG_SUFFIX "d") RUNTIME_OUTPUT_DIRECTORY_DEBUG "${runtime_out_dir}/Debug"
# endif () RUNTIME_OUTPUT_DIRECTORY_RELEASE "${runtime_out_dir}/Release"
# set(QT_INSTALL_PATH "${CMAKE_PREFIX_PATH}") RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${runtime_out_dir}/RelWithDebInfo"
# if (NOT EXISTS "${QT_INSTALL_PATH}/bin") RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${runtime_out_dir}/MinSizeRel"
# set(QT_INSTALL_PATH "${QT_INSTALL_PATH}/..") )
# if (NOT EXISTS "${QT_INSTALL_PATH}/bin")
# set(QT_INSTALL_PATH "${QT_INSTALL_PATH}/..") include(GNUInstallDirs)
# endif () install(TARGETS TactileIpc3D
# endif () RUNTIME DESTINATION bin
# 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 ()

1
Configure Normal file
View File

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

View File

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

1
VERSION.txt Normal file
View File

@@ -0,0 +1 @@
1.0.0

View File

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

View File

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

View File

@@ -125,12 +125,9 @@ int main(int argc, char *argv[]) {
return; return;
glw->submitValues(frame.data); glw->submitValues(frame.data);
}); */ }); */
backend.data()->setLiveRenderCallback([](const DataFrame &frame) { backend.data()->setLiveRenderCallback([glw](const DataFrame &frame) {
if (frame.data.size() != 0) { 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 glw->submitValues(frame.data);
// 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();
} }
}); });
// TODO:待实现内容(将frame数据分发给右侧曲线/指标的QML接口) // TODO:待实现内容(将frame数据分发给右侧曲线/指标的QML接口)

View File

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

View File

@@ -104,7 +104,7 @@ Rectangle {
ComboBox { ComboBox {
Layout.fillWidth: true Layout.fillWidth: true
model: ["9600", "57600", "115200", "230400", "912600"] model: ["9600", "57600", "115200", "230400", "921600"]
Component.onCompleted: { Component.onCompleted: {
const idx = model.indexOf(String(Backend.serial.baudRate)) const idx = model.indexOf(String(Backend.serial.baudRate))
if (idx >= 0) if (idx >= 0)
@@ -469,8 +469,7 @@ Rectangle {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
onClicked: { onClicked: {
lowColorDialog.selectedColor = Backend.colorLow lowColorDialog.openWith(Backend.colorLow)
lowColorDialog.open()
} }
} }
} }
@@ -478,8 +477,7 @@ Rectangle {
text: root.tr("选择") text: root.tr("选择")
Layout.fillWidth: true Layout.fillWidth: true
onClicked: { onClicked: {
lowColorDialog.selectedColor = Backend.colorLow lowColorDialog.openWith(Backend.colorLow)
lowColorDialog.open()
} }
} }
} }
@@ -503,8 +501,7 @@ Rectangle {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
onClicked: { onClicked: {
midColorDialog.selectedColor = Backend.colorMid midColorDialog.openWith(Backend.colorMid)
midColorDialog.open()
} }
} }
} }
@@ -512,8 +509,7 @@ Rectangle {
text: root.tr("选择") text: root.tr("选择")
Layout.fillWidth: true Layout.fillWidth: true
onClicked: { onClicked: {
midColorDialog.selectedColor = Backend.colorMid midColorDialog.openWith(Backend.colorMid)
midColorDialog.open()
} }
} }
} }
@@ -537,8 +533,7 @@ Rectangle {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
onClicked: { onClicked: {
highColorDialog.selectedColor = Backend.colorHigh highColorDialog.openWith(Backend.colorHigh)
highColorDialog.open()
} }
} }
} }
@@ -546,8 +541,7 @@ Rectangle {
text: root.tr("选择") text: root.tr("选择")
Layout.fillWidth: true Layout.fillWidth: true
onClicked: { onClicked: {
highColorDialog.selectedColor = Backend.colorHigh highColorDialog.openWith(Backend.colorHigh)
highColorDialog.open()
} }
} }
} }
@@ -596,22 +590,22 @@ Rectangle {
} }
} }
ColorDialog { ColorPickerDialog {
id: lowColorDialog id: lowColorDialog
title: root.tr("选择低色") title: root.tr("选择低色")
onAccepted: Backend.colorLow = selectedColor onAccepted: Backend.colorLow = c
} }
ColorDialog { ColorPickerDialog {
id: midColorDialog id: midColorDialog
title: root.tr("选择中色") title: root.tr("选择中色")
onAccepted: Backend.colorMid = selectedColor onAccepted: Backend.colorMid = c
} }
ColorDialog { ColorPickerDialog {
id: highColorDialog id: highColorDialog
title: root.tr("选择高色") title: root.tr("选择高色")
onAccepted: Backend.colorHigh = selectedColor onAccepted: Backend.colorHigh = c
} }
SaveAsExportDialog { SaveAsExportDialog {

View File

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

49
src/globalhelper.h Normal file
View File

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

View File

@@ -8,7 +8,6 @@
#include <QtMath> #include <QtMath>
#include <QFile> #include <QFile>
#include <QDebug> #include <QDebug>
#include <GL/gl.h>
#include <qevent.h> #include <qevent.h>
#include <qlogging.h> #include <qlogging.h>
#include <qminmax.h> #include <qminmax.h>

View File

@@ -11,11 +11,8 @@
#include <vector> #include <vector>
SerialBackend::SerialBackend(QObject *parent) SerialBackend::SerialBackend(QObject *parent)
: QObject(parent) : QObject(parent), m_packetQueue(2048), m_frameQueue(2048), m_readThread(&m_packetQueue), m_decodeThread(&m_packetQueue, &m_frameQueue)
, m_packetQueue(2048) {
, m_frameQueue(2048)
, m_readThread(&m_packetQueue)
, m_decodeThread(&m_packetQueue, &m_frameQueue) {
m_request.dataLength = 24; m_request.dataLength = 24;
m_spec.model = QStringLiteral("PZR-A"); m_spec.model = QStringLiteral("PZR-A");
@@ -27,27 +24,27 @@ SerialBackend::SerialBackend(QObject* parent)
m_sendWorker = new SerialSendWorker(); m_sendWorker = new SerialSendWorker();
m_sendWorker->moveToThread(&m_sendThread); 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 0
if (!data.isEmpty()) if (!data.isEmpty())
qDebug().noquote() << "Serial recv bytes:" << QString::fromLatin1(data.toHex(' ')); qDebug().noquote() << "Serial recv bytes:" << QString::fromLatin1(data.toHex(' '));
#endif #endif
m_readThread.enqueueBytes(data); m_readThread.enqueueBytes(data); });
});
connect(m_sendWorker, &SerialSendWorker::requestBuilt, this, &SerialBackend::requestBuilt); 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()) 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()) if (!error.isEmpty())
qWarning().noquote() << "Serial packet invalid:" << error; qWarning().noquote() << "Serial packet invalid:" << error; });
}); connect(&m_decodeThread, &SerialDecodeThread::decodeError, this, [](const QString &error)
connect(&m_decodeThread, &SerialDecodeThread::decodeError, this, [](const QString& error) { {
if (!error.isEmpty()) if (!error.isEmpty())
qWarning().noquote() << "Serial decode failed:" << error; qWarning().noquote() << "Serial decode failed:" << error; });
});
connect(&m_decodeThread, &SerialDecodeThread::frameAvailable, this, &SerialBackend::drainFrames_); connect(&m_decodeThread, &SerialDecodeThread::frameAvailable, this, &SerialBackend::drainFrames_);
m_sendThread.start(); m_sendThread.start();
@@ -59,21 +56,24 @@ SerialBackend::SerialBackend(QObject* parent)
refreshPorts(); refreshPorts();
} }
SerialBackend::~SerialBackend() { SerialBackend::~SerialBackend()
{
close(); close();
stopPipeline_(); stopPipeline_();
if (m_sendWorker && m_sendThread.isRunning()) { if (m_sendWorker && m_sendThread.isRunning())
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker]() { {
worker->closeTransport(); QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker]()
}, Qt::BlockingQueuedConnection); { worker->closeTransport(); }, Qt::BlockingQueuedConnection);
} }
if (m_sendWorker && m_sendThread.isRunning()) { if (m_sendWorker && m_sendThread.isRunning())
{
QMetaObject::invokeMethod(m_sendWorker, &QObject::deleteLater, Qt::QueuedConnection); QMetaObject::invokeMethod(m_sendWorker, &QObject::deleteLater, Qt::QueuedConnection);
} }
if (m_sendThread.isRunning()) { if (m_sendThread.isRunning())
{
m_sendThread.quit(); m_sendThread.quit();
m_sendThread.wait(); m_sendThread.wait();
} }
@@ -81,15 +81,18 @@ SerialBackend::~SerialBackend() {
m_sendWorker = nullptr; m_sendWorker = nullptr;
} }
QString SerialBackend::mode() const { QString SerialBackend::mode() const
{
return (m_config.mode == DeviceMode::Slave) ? QStringLiteral("slave") : QStringLiteral("master"); 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); 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) if (m_config.portName == name)
return; return;
m_config.portName = name; m_config.portName = name;
@@ -97,7 +100,8 @@ void SerialBackend::setPortName(const QString& name) {
emit portNameChanged(); emit portNameChanged();
} }
void SerialBackend::setBaudRate(int rate) { void SerialBackend::setBaudRate(int rate)
{
if (m_config.baudRate == rate) if (m_config.baudRate == rate)
return; return;
m_config.baudRate = rate; m_config.baudRate = rate;
@@ -105,7 +109,8 @@ void SerialBackend::setBaudRate(int rate) {
emit baudRateChanged(); emit baudRateChanged();
} }
void SerialBackend::setPollIntervalMs(int intervalMs) { void SerialBackend::setPollIntervalMs(int intervalMs)
{
intervalMs = qMax(1, intervalMs); intervalMs = qMax(1, intervalMs);
if (m_config.pollIntervalMs == intervalMs) if (m_config.pollIntervalMs == intervalMs)
return; return;
@@ -114,7 +119,8 @@ void SerialBackend::setPollIntervalMs(int intervalMs) {
emit pollIntervalMsChanged(); emit pollIntervalMsChanged();
} }
void SerialBackend::setDeviceAddress(int address) { void SerialBackend::setDeviceAddress(int address)
{
const int capped = qBound(0, address, 255); const int capped = qBound(0, address, 255);
if (m_config.deviceAddress == static_cast<quint8>(capped)) if (m_config.deviceAddress == static_cast<quint8>(capped))
return; return;
@@ -123,7 +129,8 @@ void SerialBackend::setDeviceAddress(int address) {
emit deviceAddressChanged(); emit deviceAddressChanged();
} }
void SerialBackend::setMode(const QString& mode) { void SerialBackend::setMode(const QString &mode)
{
const QString lower = mode.trimmed().toLower(); const QString lower = mode.trimmed().toLower();
const DeviceMode next = (lower == QStringLiteral("master")) ? DeviceMode::Master : DeviceMode::Slave; const DeviceMode next = (lower == QStringLiteral("master")) ? DeviceMode::Master : DeviceMode::Slave;
if (m_config.mode == next) if (m_config.mode == next)
@@ -133,7 +140,8 @@ void SerialBackend::setMode(const QString& mode) {
emit modeChanged(); emit modeChanged();
} }
void SerialBackend::setRequestFunction(int func) { void SerialBackend::setRequestFunction(int func)
{
const int capped = qBound(0, func, 255); const int capped = qBound(0, func, 255);
if (m_request.functionCode == static_cast<quint8>(capped)) if (m_request.functionCode == static_cast<quint8>(capped))
return; return;
@@ -142,7 +150,8 @@ void SerialBackend::setRequestFunction(int func) {
emit requestFunctionChanged(); emit requestFunctionChanged();
} }
void SerialBackend::setRequestStartAddress(int addr) { void SerialBackend::setRequestStartAddress(int addr)
{
const quint32 capped = static_cast<quint32>(qMax(0, addr)); const quint32 capped = static_cast<quint32>(qMax(0, addr));
if (m_request.startAddress == capped) if (m_request.startAddress == capped)
return; return;
@@ -151,7 +160,8 @@ void SerialBackend::setRequestStartAddress(int addr) {
emit requestStartAddressChanged(); emit requestStartAddressChanged();
} }
void SerialBackend::setRequestLength(int len) { void SerialBackend::setRequestLength(int len)
{
const int capped = qBound(0, len, 65535); const int capped = qBound(0, len, 65535);
if (m_request.dataLength == static_cast<quint16>(capped)) if (m_request.dataLength == static_cast<quint16>(capped))
return; return;
@@ -160,26 +170,30 @@ void SerialBackend::setRequestLength(int len) {
emit requestLengthChanged(); emit requestLengthChanged();
} }
void SerialBackend::setProtocol(const QString& name) { void SerialBackend::setProtocol(const QString &name)
{
if (!m_manager.setActiveProtocol(name)) if (!m_manager.setActiveProtocol(name))
return; return;
updateProtocolBindings_(); updateProtocolBindings_();
emit protocolChanged(); 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 QString nextModel = model.trimmed();
const int nextRows = qMax(0, rows); const int nextRows = qMax(0, rows);
const int nextCols = qMax(0, cols); const int nextCols = qMax(0, cols);
bool changed = false; bool changed = false;
if (!nextModel.isEmpty() && m_spec.model != nextModel) { if (!nextModel.isEmpty() && m_spec.model != nextModel)
{
m_spec.model = nextModel; m_spec.model = nextModel;
emit sensorModelChanged(); emit sensorModelChanged();
changed = true; 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.rows = nextRows;
m_spec.cols = nextCols; m_spec.cols = nextCols;
emit sensorGridChanged(); emit sensorGridChanged();
@@ -190,34 +204,38 @@ void SerialBackend::applySensorSpec(const QString& model, int rows, int cols) {
return; return;
} }
void SerialBackend::setTransport(std::unique_ptr<ISerialTransport> transport) { void SerialBackend::setTransport(std::unique_ptr<ISerialTransport> transport)
{
if (!transport || !m_sendWorker) if (!transport || !m_sendWorker)
return; return;
if (!m_sendThread.isRunning()) if (!m_sendThread.isRunning())
m_sendThread.start(); m_sendThread.start();
if (transport->thread() != &m_sendThread) if (transport->thread() != &m_sendThread)
transport->moveToThread(&m_sendThread); transport->moveToThread(&m_sendThread);
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, transport = std::move(transport)]() mutable { QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, transport = std::move(transport)]() mutable
worker->setTransport(std::move(transport)); { worker->setTransport(std::move(transport)); }, Qt::BlockingQueuedConnection);
}, Qt::BlockingQueuedConnection);
} }
void SerialBackend::refreshPorts() { void SerialBackend::refreshPorts()
{
// Placeholder for real port discovery (QtSerialPort or third-party transport). // Placeholder for real port discovery (QtSerialPort or third-party transport).
m_availablePorts.clear(); m_availablePorts.clear();
auto device_found = QSerialPortInfo::availablePorts(); auto device_found = QSerialPortInfo::availablePorts();
for (auto item : device_found) { for (auto item : device_found)
{
m_availablePorts.append(item.portName()); 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(); m_config.portName = m_availablePorts.first();
emit portNameChanged(); emit portNameChanged();
} }
emit availablePortsChanged(); emit availablePortsChanged();
} }
bool SerialBackend::open() { bool SerialBackend::open()
{
if (m_connected) if (m_connected)
return true; return true;
@@ -225,12 +243,13 @@ bool SerialBackend::open() {
bool ok = false; bool ok = false;
QString error; QString error;
if (m_sendWorker) { if (m_sendWorker)
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, config = m_config, &ok, &error]() { {
ok = worker->openTransport(config, &error); QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, config = m_config, &ok, &error]()
}, Qt::BlockingQueuedConnection); { ok = worker->openTransport(config, &error); }, Qt::BlockingQueuedConnection);
} }
if (!ok) { if (!ok)
{
qWarning().noquote() << "Serial open failed:" << error; qWarning().noquote() << "Serial open failed:" << error;
return false; return false;
} }
@@ -241,44 +260,49 @@ bool SerialBackend::open() {
return true; return true;
} }
void SerialBackend::close() { void SerialBackend::close()
{
if (!m_connected) if (!m_connected)
return; return;
if (m_sendWorker && m_sendThread.isRunning()) { if (m_sendWorker && m_sendThread.isRunning())
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker]() { {
worker->closeTransport(); QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker]()
}, Qt::BlockingQueuedConnection); { worker->closeTransport(); }, Qt::BlockingQueuedConnection);
} }
stopPipeline_(); stopPipeline_();
m_connected = false; m_connected = false;
emit connectedChanged(); emit connectedChanged();
} }
void SerialBackend::requestOnce() { void SerialBackend::requestOnce()
{
if (!m_sendWorker) if (!m_sendWorker)
return; return;
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker]() { QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker]()
worker->requestOnce(); { worker->requestOnce(); }, Qt::QueuedConnection);
}, Qt::QueuedConnection);
} }
void SerialBackend::feedBytes(const QByteArray& data) { void SerialBackend::feedBytes(const QByteArray &data)
{
if (!m_readThread.isRunning()) if (!m_readThread.isRunning())
startPipeline_(); startPipeline_();
m_readThread.enqueueBytes(data); m_readThread.enqueueBytes(data);
} }
void SerialBackend::setSensorWidth(int w) { void SerialBackend::setSensorWidth(int w)
{
m_spec.cols = w; m_spec.cols = w;
syncSendConfig_(); syncSendConfig_();
} }
void SerialBackend::setSensorHeight(int h) { void SerialBackend::setSensorHeight(int h)
{
m_spec.rows = h; m_spec.rows = h;
syncSendConfig_(); syncSendConfig_();
} }
void SerialBackend::drainFrames_() { void SerialBackend::drainFrames_()
{
if (!m_frameCallback) if (!m_frameCallback)
return; return;
DataFrame frame; DataFrame frame;
@@ -286,7 +310,8 @@ void SerialBackend::drainFrames_() {
m_frameCallback(frame); m_frameCallback(frame);
} }
void SerialBackend::startPipeline_() { void SerialBackend::startPipeline_()
{
if (m_readThread.isRunning() || m_decodeThread.isRunning()) if (m_readThread.isRunning() || m_decodeThread.isRunning())
stopPipeline_(); stopPipeline_();
m_packetQueue.reset(); m_packetQueue.reset();
@@ -300,12 +325,15 @@ void SerialBackend::startPipeline_() {
m_decodeThread.start(); m_decodeThread.start();
} }
void SerialBackend::stopPipeline_() { void SerialBackend::stopPipeline_()
if (m_readThread.isRunning()) { {
if (m_readThread.isRunning())
{
m_readThread.stop(); m_readThread.stop();
m_readThread.wait(); m_readThread.wait();
} }
if (m_decodeThread.isRunning()) { if (m_decodeThread.isRunning())
{
m_decodeThread.stop(); m_decodeThread.stop();
m_decodeThread.wait(); m_decodeThread.wait();
} }
@@ -314,53 +342,60 @@ void SerialBackend::stopPipeline_() {
m_readThread.clear(); m_readThread.clear();
} }
void SerialBackend::updateProtocolBindings_() { void SerialBackend::updateProtocolBindings_()
{
const auto bundle = m_manager.activeBundle(); const auto bundle = m_manager.activeBundle();
SerialReadThread::ParseFunc parseFunc; SerialReadThread::ParseFunc parseFunc;
if (bundle.format) { if (bundle.format)
parseFunc = [format = bundle.format](QByteArray* buffer, QByteArray* packet, QString* error) { {
parseFunc = [format = bundle.format](QByteArray *buffer, QByteArray *packet, QString *error)
{
return format->tryParse(buffer, packet, error); return format->tryParse(buffer, packet, error);
}; };
} }
m_readThread.setParseFunc(std::move(parseFunc)); m_readThread.setParseFunc(std::move(parseFunc));
SerialDecodeThread::DecodeFunc decodeFunc; SerialDecodeThread::DecodeFunc decodeFunc;
if (bundle.decoder) { if (bundle.decoder)
decodeFunc = [decoder = bundle.decoder](const QByteArray& packet, DataFrame* frame, QString* error) { {
decodeFunc = [decoder = bundle.decoder](const QByteArray &packet, DataFrame *frame, QString *error)
{
return decoder->decodeFrame(packet, frame, error); return decoder->decodeFrame(packet, frame, error);
}; };
} }
m_decodeThread.setDecodeFunc(std::move(decodeFunc)); m_decodeThread.setDecodeFunc(std::move(decodeFunc));
SerialSendWorker::BuildRequestFunc requestFunc; SerialSendWorker::BuildRequestFunc requestFunc;
if (bundle.codec) { if (bundle.codec)
requestFunc = [codec = bundle.codec](const SerialConfig& config, const SensorRequest& request) { {
requestFunc = [codec = bundle.codec](const SerialConfig &config, const SensorRequest &request)
{
return codec->buildRequest(config, request); return codec->buildRequest(config, request);
}; };
} }
if (m_sendWorker) { if (m_sendWorker)
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, requestFunc]() mutable { {
worker->setBuildRequestFunc(std::move(requestFunc)); QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, requestFunc]() mutable
}, Qt::QueuedConnection); { worker->setBuildRequestFunc(std::move(requestFunc)); }, Qt::QueuedConnection);
} }
} }
void SerialBackend::syncSendConfig_() { void SerialBackend::syncSendConfig_()
{
if (!m_sendWorker) if (!m_sendWorker)
return; return;
const SerialConfig config = m_config; const SerialConfig config = m_config;
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, config]() { QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, config]()
worker->setConfig(config); { worker->setConfig(config); }, Qt::QueuedConnection);
}, Qt::QueuedConnection);
} }
void SerialBackend::syncSendRequest_() { void SerialBackend::syncSendRequest_()
{
if (!m_sendWorker) if (!m_sendWorker)
return; return;
const SensorRequest request = m_request; const SensorRequest request = m_request;
QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, request]() { QMetaObject::invokeMethod(m_sendWorker, [worker = m_sendWorker, request]()
worker->setRequest(request); { worker->setRequest(request); }, Qt::QueuedConnection);
}, Qt::QueuedConnection);
} }