fix: 修复打砖块游戏碰撞穿透bug,添加渐进提速机制
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
@echo off
|
||||
REM ── JE-Skin DevKit: 打包 Python gRPC server 为 exe ──
|
||||
REM 前提: pip install pyinstaller grpcio grpcio-tools openpyxl
|
||||
REM 前提: pip install pyinstaller grpcio grpcio-tools numpy openpyxl
|
||||
|
||||
echo [1/3] Generating gRPC stubs...
|
||||
python -m grpc_tools.protoc ^
|
||||
|
||||
@@ -5,8 +5,8 @@ a = Analysis(
|
||||
['sensor_server.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=['grpc', 'openpyxl'],
|
||||
datas=[('sensor_stream_pb2.py', '.'), ('sensor_stream_pb2_grpc.py', '.')],
|
||||
hiddenimports=['grpc', 'openpyxl', 'numpy'],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
|
||||
@@ -234,30 +234,63 @@ class SensorPushServicer(sensor_stream_pb2_grpc.SensorPushServicer):
|
||||
def __init__(self):
|
||||
self.frame_count = 0
|
||||
self.last_report_time = time.time()
|
||||
self.last_angle = None
|
||||
|
||||
def Upload(self, request_iterator, context):
|
||||
print("[SensorPush] Client connected, waiting for frames...")
|
||||
reset_baseline()
|
||||
self.last_angle = None
|
||||
|
||||
for frame in request_iterator:
|
||||
self.frame_count += 1
|
||||
angle = 0.0
|
||||
ok = True
|
||||
message = "OK"
|
||||
if len(frame.matrix) == SENSOR_ROWS * SENSOR_COLS:
|
||||
try:
|
||||
angle = get_pzt_angle(frame.matrix)
|
||||
self.last_angle = angle
|
||||
if self.frame_count <= 10 or self.frame_count % 30 == 0:
|
||||
print(
|
||||
f"[SensorPush] PZT angle frame #{frame.seq} "
|
||||
f"dts={frame.dts_ms} angle={angle:.2f}"
|
||||
)
|
||||
except Exception as e:
|
||||
ok = False
|
||||
message = str(e)
|
||||
print(f"[SensorPush] PZT compute error on frame #{frame.seq}: {e}")
|
||||
else:
|
||||
ok = False
|
||||
message = f"Invalid matrix length: {len(frame.matrix)}"
|
||||
|
||||
yield sensor_stream_pb2.PztAngleResponse(
|
||||
seq=frame.seq,
|
||||
timestamp_ms=frame.timestamp_ms,
|
||||
angle=angle,
|
||||
dts_ms=frame.dts_ms,
|
||||
ok=ok,
|
||||
message=message,
|
||||
)
|
||||
|
||||
if self.frame_count % 100 == 0:
|
||||
now = time.time()
|
||||
elapsed = now - self.last_report_time
|
||||
fps = 100 / elapsed if elapsed > 0 else 0
|
||||
self.last_report_time = now
|
||||
angle_text = (
|
||||
f"{self.last_angle:.2f}"
|
||||
if self.last_angle is not None
|
||||
else "n/a"
|
||||
)
|
||||
print(
|
||||
f"[SensorPush] Frame #{frame.seq} | "
|
||||
f"{frame.rows}x{frame.cols} | "
|
||||
f"angle={angle_text} | "
|
||||
f"force={frame.resultant_force:.1f} | "
|
||||
f"total={self.frame_count} | ~{fps:.1f} fps"
|
||||
)
|
||||
|
||||
print(f"[SensorPush] Stream ended. Total: {self.frame_count}")
|
||||
return sensor_stream_pb2.UploadResponse(
|
||||
ok=True,
|
||||
frames_received=self.frame_count,
|
||||
message=f"Processed {self.frame_count} frames",
|
||||
)
|
||||
|
||||
|
||||
class ExportProcessorServicer(sensor_stream_pb2_grpc.ExportProcessorServicer):
|
||||
@@ -316,6 +349,117 @@ def serve(port: int):
|
||||
server.wait_for_termination()
|
||||
|
||||
|
||||
import numpy as np
|
||||
import threading
|
||||
|
||||
# ===================== 算法参数=====================
|
||||
TOTAL_PRESSURE_LOW_THRESHOLD = 500
|
||||
COP_STABILITY_FRAMES_REQUIRED = 5
|
||||
SENSOR_ROWS = 12
|
||||
SENSOR_COLS = 7
|
||||
|
||||
# ===================== 线程安全全局状态 =====================
|
||||
first_frame = None
|
||||
first_frame_lock = threading.Lock()
|
||||
|
||||
first_contact_CoP_x = None
|
||||
first_contact_CoP_y = None
|
||||
contact_initialized = False
|
||||
|
||||
total_pressure_low_counter = 0
|
||||
|
||||
# ===================== 基线减除 =====================
|
||||
def subtract_baseline(current_frame):
|
||||
global first_frame
|
||||
current_frame = np.array(current_frame, dtype=np.float32).flatten()
|
||||
|
||||
with first_frame_lock:
|
||||
if first_frame is None:
|
||||
first_frame = current_frame.copy()
|
||||
|
||||
diff = current_frame - first_frame
|
||||
return np.clip(diff, 0, None)
|
||||
|
||||
# ===================== 重置CoP状态 =====================
|
||||
def reset_cop_state():
|
||||
global first_contact_CoP_x, first_contact_CoP_y, contact_initialized
|
||||
global total_pressure_low_counter
|
||||
|
||||
first_contact_CoP_x = None
|
||||
first_contact_CoP_y = None
|
||||
contact_initialized = False
|
||||
total_pressure_low_counter = 0
|
||||
|
||||
# ===================== CoP压力中心计算 =====================
|
||||
def compute_pressure_direction(baseline_subtracted_frame):
|
||||
global first_contact_CoP_x, first_contact_CoP_y, contact_initialized
|
||||
global total_pressure_low_counter
|
||||
|
||||
rows, cols = SENSOR_ROWS, SENSOR_COLS
|
||||
frame_flat = np.asarray(baseline_subtracted_frame, dtype=np.float32).flatten()
|
||||
frame2d = frame_flat.reshape(rows, cols)
|
||||
|
||||
total_pressure = np.sum(frame2d)
|
||||
if total_pressure < TOTAL_PRESSURE_LOW_THRESHOLD:
|
||||
total_pressure_low_counter += 1
|
||||
else:
|
||||
total_pressure_low_counter = 0
|
||||
|
||||
if total_pressure_low_counter >= COP_STABILITY_FRAMES_REQUIRED:
|
||||
reset_cop_state()
|
||||
return 0.0, 0.0
|
||||
|
||||
if total_pressure == 0:
|
||||
return 0.0, 0.0
|
||||
|
||||
x_grid = np.tile(np.arange(cols), (rows, 1))
|
||||
y_grid = np.repeat(np.arange(rows), cols).reshape(rows, cols)
|
||||
cop_x = np.sum(frame2d * x_grid) / total_pressure
|
||||
cop_y = np.sum(frame2d * y_grid) / total_pressure
|
||||
|
||||
delta_CoP_x = 0.0
|
||||
delta_CoP_y = 0.0
|
||||
|
||||
if not contact_initialized:
|
||||
first_contact_CoP_x = cop_x
|
||||
first_contact_CoP_y = cop_y
|
||||
contact_initialized = True
|
||||
else:
|
||||
delta_CoP_x = cop_x - first_contact_CoP_x
|
||||
delta_CoP_y = cop_y - first_contact_CoP_y
|
||||
|
||||
return delta_CoP_x, delta_CoP_y
|
||||
|
||||
# ===================== 角度计算核心 =====================
|
||||
def compute_vector_angle(x: float, y: float) -> tuple[float, float]:
|
||||
epsilon = 1e-8
|
||||
mag = np.hypot(x, y)
|
||||
angle = np.degrees(np.arctan2(y, x + epsilon))
|
||||
if angle < 0:
|
||||
angle += 360
|
||||
return angle, mag
|
||||
|
||||
def compute_PZT_angle(Px: float, Py: float) -> tuple[float, float]:
|
||||
return compute_vector_angle(Px, -Py)
|
||||
|
||||
# ===================== 核心入口函数 =====================
|
||||
def get_pzt_angle(adc_data):
|
||||
if len(adc_data) != 84:
|
||||
raise ValueError("ADC数据长度必须为84")
|
||||
baseline_subtracted = subtract_baseline(adc_data)
|
||||
dx, dy = compute_pressure_direction(baseline_subtracted)
|
||||
pzt_angle, _ = compute_PZT_angle(dx, dy)
|
||||
|
||||
return pzt_angle
|
||||
|
||||
# ===================== 重置基线(校准用) =====================
|
||||
def reset_baseline():
|
||||
global first_frame
|
||||
with first_frame_lock:
|
||||
first_frame = None
|
||||
reset_cop_state()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="JE-Skin DevKit gRPC Server")
|
||||
parser.add_argument("--port", type=int, default=50051, help="gRPC listen port (default: 50051)")
|
||||
|
||||
@@ -24,7 +24,7 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13sensor_stream.proto\x12\rsensor_stream\"\x85\x01\n\x0bSensorFrame\x12\x0b\n\x03seq\x18\x01 \x01(\x04\x12\x14\n\x0ctimestamp_ms\x18\x02 \x01(\x04\x12\x0c\n\x04rows\x18\x03 \x01(\r\x12\x0c\n\x04\x63ols\x18\x04 \x01(\r\x12\x0e\n\x06matrix\x18\x05 \x03(\r\x12\x17\n\x0fresultant_force\x18\x06 \x01(\x01\x12\x0e\n\x06\x64ts_ms\x18\x07 \x01(\r\"F\n\x0eUploadResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x17\n\x0f\x66rames_received\x18\x02 \x01(\x04\x12\x0f\n\x07message\x18\x03 \x01(\t\"8\n\x0eProcessRequest\x12\x10\n\x08\x63sv_path\x18\x01 \x01(\t\x12\x14\n\x0csave_as_xlsx\x18\x02 \x01(\x08\"\xa6\x01\n\x0fProcessResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x13\n\x0boutput_path\x18\x02 \x01(\t\x12\x13\n\x0bgroups_used\x18\x03 \x01(\r\x12\x12\n\nmean_value\x18\x04 \x01(\x01\x12\x11\n\tthreshold\x18\x05 \x01(\x01\x12\x12\n\nrows_total\x18\x06 \x01(\r\x12\x11\n\trows_kept\x18\x07 \x01(\r\x12\x0f\n\x07message\x18\x08 \x01(\t2S\n\nSensorPush\x12\x45\n\x06Upload\x12\x1a.sensor_stream.SensorFrame\x1a\x1d.sensor_stream.UploadResponse(\x01\x32_\n\x0f\x45xportProcessor\x12L\n\x0bProcessFile\x12\x1d.sensor_stream.ProcessRequest\x1a\x1e.sensor_stream.ProcessResponseb\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13sensor_stream.proto\x12\rsensor_stream\"\x85\x01\n\x0bSensorFrame\x12\x0b\n\x03seq\x18\x01 \x01(\x04\x12\x14\n\x0ctimestamp_ms\x18\x02 \x01(\x04\x12\x0c\n\x04rows\x18\x03 \x01(\r\x12\x0c\n\x04\x63ols\x18\x04 \x01(\r\x12\x0e\n\x06matrix\x18\x05 \x03(\r\x12\x17\n\x0fresultant_force\x18\x06 \x01(\x01\x12\x0e\n\x06\x64ts_ms\x18\x07 \x01(\r\"q\n\x10PztAngleResponse\x12\x0b\n\x03seq\x18\x01 \x01(\x04\x12\x14\n\x0ctimestamp_ms\x18\x02 \x01(\x04\x12\r\n\x05\x61ngle\x18\x03 \x01(\x02\x12\x0e\n\x06\x64ts_ms\x18\x04 \x01(\r\x12\n\n\x02ok\x18\x05 \x01(\x08\x12\x0f\n\x07message\x18\x06 \x01(\t\"8\n\x0eProcessRequest\x12\x10\n\x08\x63sv_path\x18\x01 \x01(\t\x12\x14\n\x0csave_as_xlsx\x18\x02 \x01(\x08\"\xa6\x01\n\x0fProcessResponse\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x13\n\x0boutput_path\x18\x02 \x01(\t\x12\x13\n\x0bgroups_used\x18\x03 \x01(\r\x12\x12\n\nmean_value\x18\x04 \x01(\x01\x12\x11\n\tthreshold\x18\x05 \x01(\x01\x12\x12\n\nrows_total\x18\x06 \x01(\r\x12\x11\n\trows_kept\x18\x07 \x01(\r\x12\x0f\n\x07message\x18\x08 \x01(\t2W\n\nSensorPush\x12I\n\x06Upload\x12\x1a.sensor_stream.SensorFrame\x1a\x1f.sensor_stream.PztAngleResponse(\x01\x30\x01\x32_\n\x0f\x45xportProcessor\x12L\n\x0bProcessFile\x12\x1d.sensor_stream.ProcessRequest\x1a\x1e.sensor_stream.ProcessResponseb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
@@ -33,14 +33,14 @@ if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals['_SENSORFRAME']._serialized_start=39
|
||||
_globals['_SENSORFRAME']._serialized_end=172
|
||||
_globals['_UPLOADRESPONSE']._serialized_start=174
|
||||
_globals['_UPLOADRESPONSE']._serialized_end=244
|
||||
_globals['_PROCESSREQUEST']._serialized_start=246
|
||||
_globals['_PROCESSREQUEST']._serialized_end=302
|
||||
_globals['_PROCESSRESPONSE']._serialized_start=305
|
||||
_globals['_PROCESSRESPONSE']._serialized_end=471
|
||||
_globals['_SENSORPUSH']._serialized_start=473
|
||||
_globals['_SENSORPUSH']._serialized_end=556
|
||||
_globals['_EXPORTPROCESSOR']._serialized_start=558
|
||||
_globals['_EXPORTPROCESSOR']._serialized_end=653
|
||||
_globals['_PZTANGLERESPONSE']._serialized_start=174
|
||||
_globals['_PZTANGLERESPONSE']._serialized_end=287
|
||||
_globals['_PROCESSREQUEST']._serialized_start=289
|
||||
_globals['_PROCESSREQUEST']._serialized_end=345
|
||||
_globals['_PROCESSRESPONSE']._serialized_start=348
|
||||
_globals['_PROCESSRESPONSE']._serialized_end=514
|
||||
_globals['_SENSORPUSH']._serialized_start=516
|
||||
_globals['_SENSORPUSH']._serialized_end=603
|
||||
_globals['_EXPORTPROCESSOR']._serialized_start=605
|
||||
_globals['_EXPORTPROCESSOR']._serialized_end=700
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -26,8 +26,7 @@ if _version_not_supported:
|
||||
|
||||
|
||||
class SensorPushStub(object):
|
||||
"""传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧
|
||||
"""
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
@@ -35,16 +34,15 @@ class SensorPushStub(object):
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.Upload = channel.stream_unary(
|
||||
self.Upload = channel.stream_stream(
|
||||
'/sensor_stream.SensorPush/Upload',
|
||||
request_serializer=sensor__stream__pb2.SensorFrame.SerializeToString,
|
||||
response_deserializer=sensor__stream__pb2.UploadResponse.FromString,
|
||||
response_deserializer=sensor__stream__pb2.PztAngleResponse.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class SensorPushServicer(object):
|
||||
"""传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧
|
||||
"""
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def Upload(self, request_iterator, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
@@ -55,10 +53,10 @@ class SensorPushServicer(object):
|
||||
|
||||
def add_SensorPushServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'Upload': grpc.stream_unary_rpc_method_handler(
|
||||
'Upload': grpc.stream_stream_rpc_method_handler(
|
||||
servicer.Upload,
|
||||
request_deserializer=sensor__stream__pb2.SensorFrame.FromString,
|
||||
response_serializer=sensor__stream__pb2.UploadResponse.SerializeToString,
|
||||
response_serializer=sensor__stream__pb2.PztAngleResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
@@ -69,8 +67,7 @@ def add_SensorPushServicer_to_server(servicer, server):
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class SensorPush(object):
|
||||
"""传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧
|
||||
"""
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
@staticmethod
|
||||
def Upload(request_iterator,
|
||||
@@ -83,12 +80,12 @@ class SensorPush(object):
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.stream_unary(
|
||||
return grpc.experimental.stream_stream(
|
||||
request_iterator,
|
||||
target,
|
||||
'/sensor_stream.SensorPush/Upload',
|
||||
sensor__stream__pb2.SensorFrame.SerializeToString,
|
||||
sensor__stream__pb2.UploadResponse.FromString,
|
||||
sensor__stream__pb2.PztAngleResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
@@ -101,8 +98,7 @@ class SensorPush(object):
|
||||
|
||||
|
||||
class ExportProcessorStub(object):
|
||||
"""导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤
|
||||
"""
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
@@ -118,8 +114,7 @@ class ExportProcessorStub(object):
|
||||
|
||||
|
||||
class ExportProcessorServicer(object):
|
||||
"""导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤
|
||||
"""
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def ProcessFile(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
@@ -144,8 +139,7 @@ def add_ExportProcessorServicer_to_server(servicer, server):
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class ExportProcessor(object):
|
||||
"""导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤
|
||||
"""
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
@staticmethod
|
||||
def ProcessFile(request,
|
||||
|
||||
48
src-tauri/Cargo.lock
generated
48
src-tauri/Cargo.lock
generated
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"humantime",
|
||||
"log",
|
||||
"ndarray",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"protoc-bin-vendored",
|
||||
@@ -2441,6 +2442,16 @@ version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -2530,6 +2541,19 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.15.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2595,12 +2619,30 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -3605,6 +3647,12 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
||||
@@ -17,6 +17,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
[features]
|
||||
default = []
|
||||
devkit = ["dep:tonic", "dep:prost", "dep:prost-types", "dep:async-stream", "dep:dirs"]
|
||||
multi-dim = ["dep:ndarray"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
@@ -51,6 +52,7 @@ futures-util = "0.3"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
ndarray = { version = "0.15", optional = true }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
@@ -2,17 +2,14 @@ syntax = "proto3";
|
||||
|
||||
package sensor_stream;
|
||||
|
||||
// 传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧
|
||||
service SensorPush {
|
||||
rpc Upload (stream SensorFrame) returns (UploadResponse);
|
||||
rpc Upload(stream SensorFrame) returns (stream PztAngleResponse);
|
||||
}
|
||||
|
||||
// 导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤
|
||||
service ExportProcessor {
|
||||
rpc ProcessFile(ProcessRequest) returns (ProcessResponse);
|
||||
}
|
||||
|
||||
// 一帧传感器数据
|
||||
message SensorFrame {
|
||||
uint64 seq = 1;
|
||||
uint64 timestamp_ms = 2;
|
||||
@@ -23,27 +20,27 @@ message SensorFrame {
|
||||
uint32 dts_ms = 7;
|
||||
}
|
||||
|
||||
// 上传确认响应
|
||||
message UploadResponse {
|
||||
bool ok = 1;
|
||||
uint64 frames_received = 2;
|
||||
string message = 3;
|
||||
message PztAngleResponse {
|
||||
uint64 seq = 1;
|
||||
uint64 timestamp_ms = 2;
|
||||
float angle = 3;
|
||||
uint32 dts_ms = 4;
|
||||
bool ok = 5;
|
||||
string message = 6;
|
||||
}
|
||||
|
||||
// 导出处理请求
|
||||
message ProcessRequest {
|
||||
string csv_path = 1; // 导出的 CSV 文件路径
|
||||
bool save_as_xlsx = 2; // 是否以 xlsx 保存(删除源 CSV)
|
||||
string csv_path = 1;
|
||||
bool save_as_xlsx = 2;
|
||||
}
|
||||
|
||||
// 导出处理响应
|
||||
message ProcessResponse {
|
||||
bool ok = 1;
|
||||
string output_path = 2; // 输出文件路径
|
||||
uint32 groups_used = 3; // 分组数
|
||||
double mean_value = 4; // 均值
|
||||
double threshold = 5; // 梯度阈值
|
||||
uint32 rows_total = 6; // 原始行数
|
||||
uint32 rows_kept = 7; // 保留行数
|
||||
string output_path = 2;
|
||||
uint32 groups_used = 3;
|
||||
double mean_value = 4;
|
||||
double threshold = 5;
|
||||
uint32 rows_total = 6;
|
||||
uint32 rows_kept = 7;
|
||||
string message = 8;
|
||||
}
|
||||
Binary file not shown.
@@ -3,6 +3,8 @@
|
||||
//! 仅在 `devkit` feature 启用时编译。
|
||||
|
||||
use tauri::State;
|
||||
#[cfg(feature = "devkit")]
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::devkit::{DevKitConfig, DevKitState, DevKitStatusSnapshot, ExportProcessResult};
|
||||
|
||||
@@ -12,9 +14,13 @@ pub fn devkit_status(state: State<'_, DevKitState>) -> DevKitStatusSnapshot {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn devkit_start(state: State<'_, DevKitState>, port: Option<u16>) -> Result<DevKitStatusSnapshot, String> {
|
||||
pub async fn devkit_start(
|
||||
app: AppHandle,
|
||||
state: State<'_, DevKitState>,
|
||||
port: Option<u16>,
|
||||
) -> Result<DevKitStatusSnapshot, String> {
|
||||
let target_port = port.unwrap_or(50051);
|
||||
state.start(target_port).await?;
|
||||
state.start(app, target_port).await?;
|
||||
Ok(state.status())
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,24 @@ use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::proto::sensor_push_client::SensorPushClient;
|
||||
use super::proto::export_processor_client::ExportProcessorClient;
|
||||
use super::proto::{ProcessRequest, SensorFrame};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DevKitPztAngleEvent {
|
||||
seq: u64,
|
||||
timestamp_ms: u64,
|
||||
dts_ms: u32,
|
||||
angle: f32,
|
||||
}
|
||||
|
||||
// ── DevKit 配置 ────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -145,7 +155,7 @@ impl DevKitState {
|
||||
}
|
||||
|
||||
/// 启动 gRPC client,连接到 Python server 并开始推送数据
|
||||
pub async fn start(&self, port: u16) -> Result<(), String> {
|
||||
pub async fn start(&self, app: AppHandle, port: u16) -> Result<(), String> {
|
||||
if self.running.load(Ordering::SeqCst) {
|
||||
return Err("AlreadyRunning".into());
|
||||
}
|
||||
@@ -161,9 +171,10 @@ impl DevKitState {
|
||||
|
||||
let running = Arc::clone(&self.running);
|
||||
let frame_count = Arc::clone(&self.frame_count);
|
||||
let app_handle = app.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = run_grpc_upload(addr, rx, frame_count).await {
|
||||
if let Err(e) = run_grpc_upload(app_handle, addr, rx, frame_count).await {
|
||||
::log::error!("DevKit gRPC upload error: {e:?}");
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
@@ -241,6 +252,7 @@ impl DevKitState {
|
||||
// ── gRPC Upload Client ─────────────────────────────────────────────
|
||||
|
||||
async fn run_grpc_upload(
|
||||
app: AppHandle,
|
||||
addr: String,
|
||||
mut rx: mpsc::Receiver<SensorFrame>,
|
||||
frame_count: Arc<AtomicU32>,
|
||||
@@ -255,14 +267,29 @@ async fn run_grpc_upload(
|
||||
};
|
||||
|
||||
let response = client.upload(stream).await?;
|
||||
let resp = response.into_inner();
|
||||
let mut inbound = response.into_inner();
|
||||
|
||||
::log::info!(
|
||||
"DevKit upload complete: ok={}, frames={}, msg={}",
|
||||
resp.ok,
|
||||
resp.frames_received,
|
||||
resp.message
|
||||
while let Some(message) = inbound.message().await? {
|
||||
if message.ok {
|
||||
let payload = DevKitPztAngleEvent {
|
||||
seq: message.seq,
|
||||
timestamp_ms: message.timestamp_ms,
|
||||
dts_ms: message.dts_ms,
|
||||
angle: message.angle,
|
||||
};
|
||||
::log::debug!(
|
||||
"python pzt angle: seq={} dts_ms={} angle={:.2}",
|
||||
message.seq,
|
||||
message.dts_ms,
|
||||
message.angle
|
||||
);
|
||||
app.emit("devkit_pzt_angle", payload)?;
|
||||
} else {
|
||||
::log::warn!("DevKit PZT response error: {}", message.message);
|
||||
}
|
||||
}
|
||||
|
||||
::log::info!("DevKit upload stream closed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -22,11 +22,43 @@ fn start_server_exe(exe_path: &std::path::Path) {
|
||||
}
|
||||
|
||||
match command.spawn() {
|
||||
Ok(_) => ::log::info!("DevKit Python server started: {}", exe_path.display()),
|
||||
Ok(_) => ::log::info!("DevKit Python server launched: {}", exe_path.display()),
|
||||
Err(error) => ::log::warn!("Failed to start DevKit Python server: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "devkit")]
|
||||
fn is_local_port_open(port: u16) -> bool {
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::time::Duration;
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
TcpStream::connect_timeout(&addr, Duration::from_millis(250)).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(feature = "devkit")]
|
||||
fn find_server_exe(
|
||||
resource_dir: &std::path::Path,
|
||||
exe_name: &str,
|
||||
) -> Option<std::path::PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
candidates.push(resource_dir.join(exe_name));
|
||||
|
||||
if let Ok(current_exe) = std::env::current_exe() {
|
||||
if let Some(parent) = current_exe.parent() {
|
||||
candidates.push(parent.join(exe_name));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(current_dir) = std::env::current_dir() {
|
||||
candidates.push(current_dir.join("src-tauri").join("resources").join(exe_name));
|
||||
candidates.push(current_dir.join("devkit").join("dist").join(exe_name));
|
||||
candidates.push(current_dir.join("resources").join(exe_name));
|
||||
}
|
||||
|
||||
candidates.into_iter().find(|path| path.exists())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
@@ -56,23 +88,22 @@ pub fn run() {
|
||||
.path()
|
||||
.resource_dir()
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("./resources"));
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let devkit_port = 50051u16;
|
||||
#[cfg(target_os = "windows")]
|
||||
let exe_name = "je-skin-devkit-server.exe";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let exe_name = "je-skin-devkit-server";
|
||||
|
||||
let bundled_exe = resource_dir.join(exe_name);
|
||||
let fallback_exe = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|path| path.parent().map(|parent| parent.join(exe_name)));
|
||||
|
||||
let server_exe = if bundled_exe.exists() {
|
||||
Some(bundled_exe)
|
||||
if is_local_port_open(devkit_port) {
|
||||
::log::info!(
|
||||
"DevKit port {} already in use, skipping Python server auto-start",
|
||||
devkit_port
|
||||
);
|
||||
} else {
|
||||
fallback_exe.filter(|path| path.exists())
|
||||
};
|
||||
let server_exe = find_server_exe(&resource_dir, exe_name);
|
||||
|
||||
if let Some(exe_path) = server_exe {
|
||||
start_server_exe(&exe_path);
|
||||
@@ -80,11 +111,12 @@ pub fn run() {
|
||||
} else {
|
||||
::log::info!("DevKit Python server not found, skipping auto-start");
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(error) = devkit_state_clone.start(50051).await {
|
||||
if let Err(error) = devkit_state_clone.start(app_handle, devkit_port).await {
|
||||
::log::warn!("DevKit auto-start failed: {error}");
|
||||
} else {
|
||||
::log::info!("DevKit auto-started on 127.0.0.1:50051");
|
||||
::log::info!("DevKit gRPC client initialized for 127.0.0.1:{devkit_port}");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ pub mod model;
|
||||
pub mod serial;
|
||||
pub mod record;
|
||||
pub mod utils;
|
||||
#[cfg(feature = "multi-dim")]
|
||||
pub mod multi_dim_force;
|
||||
|
||||
pub type TestRecording = Recording<TestFrame>;
|
||||
pub type TactileARecording = Recording<TactileAFrame>;
|
||||
|
||||
122
src-tauri/src/serial_core/multi_dim_force.rs
Normal file
122
src-tauri/src/serial_core/multi_dim_force.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use ndarray::Array2;
|
||||
|
||||
const TOTAL_PRESSURE_LOW_THRESHOLD: usize = 500;
|
||||
const COP_STABILITY_FRAMES_REQUIRED: usize = 5;
|
||||
const SENSOR_ROWS: usize = 12;
|
||||
const SENSOR_COLS: usize = 7;
|
||||
|
||||
pub struct PztProcessor {
|
||||
first_frame: Option<Vec<f32>>,
|
||||
first_contact_cop_x: Option<f32>,
|
||||
first_contact_cop_y: Option<f32>,
|
||||
contact_initialized: bool,
|
||||
total_pressure_low_counter: usize,
|
||||
}
|
||||
|
||||
impl PztProcessor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
first_frame: None,
|
||||
first_contact_cop_x: None,
|
||||
first_contact_cop_y: None,
|
||||
contact_initialized: false,
|
||||
total_pressure_low_counter: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn subtract_baseline(&mut self, current_frame: &[f32]) -> Vec<f32> {
|
||||
if self.first_frame.is_none() {
|
||||
self.first_frame = Some(current_frame.to_vec());
|
||||
}
|
||||
|
||||
let baseline = self.first_frame.as_ref().unwrap();
|
||||
current_frame
|
||||
.iter()
|
||||
.zip(baseline.iter())
|
||||
.map(|(c, b)| (c - b).max(0.0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reset_cop_state(&mut self) {
|
||||
self.first_contact_cop_x = None;
|
||||
self.first_contact_cop_y = None;
|
||||
self.contact_initialized = false;
|
||||
self.total_pressure_low_counter = 0;
|
||||
}
|
||||
|
||||
fn compute_pressure_direction(&mut self, frame: &[f32]) -> (f32, f32) {
|
||||
let frame2d = Array2::from_shape_vec((SENSOR_ROWS, SENSOR_COLS), frame.to_vec()).unwrap();
|
||||
let total_pressure: f32 = frame2d.sum();
|
||||
if total_pressure < TOTAL_PRESSURE_LOW_THRESHOLD as f32 {
|
||||
self.total_pressure_low_counter += 1;
|
||||
} else {
|
||||
self.total_pressure_low_counter = 0;
|
||||
}
|
||||
|
||||
if self.total_pressure_low_counter >= COP_STABILITY_FRAMES_REQUIRED {
|
||||
self.reset_cop_state();
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
|
||||
if total_pressure == 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
|
||||
let mut sum_x = 0.0;
|
||||
let mut sum_y = 0.0;
|
||||
|
||||
for r in 0..SENSOR_ROWS {
|
||||
for c in 0..SENSOR_COLS {
|
||||
let val = frame2d[(r, c)];
|
||||
sum_x += val * c as f32;
|
||||
sum_y += val * r as f32;
|
||||
}
|
||||
}
|
||||
|
||||
let cop_x = sum_x / total_pressure;
|
||||
let cop_y = sum_y / total_pressure;
|
||||
|
||||
if !self.contact_initialized {
|
||||
self.first_contact_cop_x = Some(cop_x);
|
||||
self.first_contact_cop_y = Some(cop_y);
|
||||
self.contact_initialized = true;
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
|
||||
let dx = cop_x - self.first_contact_cop_x.unwrap();
|
||||
let dy = cop_y - self.first_contact_cop_y.unwrap();
|
||||
|
||||
(dx, dy)
|
||||
}
|
||||
|
||||
fn compute_vector_angle(x: f32, y: f32) -> (f32, f32) {
|
||||
let epsilon = 1e-8;
|
||||
let mag = (x * x + y * y).sqrt();
|
||||
let mut angle = (y).atan2(x + epsilon).to_degrees();
|
||||
if angle < 0.0 {
|
||||
angle += 360.0;
|
||||
}
|
||||
(angle, mag)
|
||||
}
|
||||
|
||||
fn compute_pzt_angle(px: f32, py: f32) -> (f32, f32) {
|
||||
Self::compute_vector_angle(px, -py)
|
||||
}
|
||||
|
||||
pub fn get_pzt_angle(&mut self, adc_data: &[f32]) -> Result<f32, &'static str> {
|
||||
if adc_data.len() != 84 {
|
||||
return Err("ADC data length must be 84");
|
||||
}
|
||||
|
||||
let baseline = self.subtract_baseline(adc_data);
|
||||
let (dx, dy) = self.compute_pressure_direction(&baseline);
|
||||
let (angle, _) = Self::compute_pzt_angle(dx, dy);
|
||||
|
||||
Ok(angle)
|
||||
}
|
||||
|
||||
pub fn reset_baseline(&mut self) {
|
||||
self.first_frame = None;
|
||||
self.reset_cop_state();
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,17 @@ use crate::serial_core::codec::Codec;
|
||||
use crate::serial_core::codecs::tactile_a::TactileACodec;
|
||||
use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame};
|
||||
use crate::serial_core::model::{HudChartState, HudPacket};
|
||||
#[cfg(feature = "multi-dim")]
|
||||
use crate::serial_core::multi_dim_force::PztProcessor;
|
||||
use crate::serial_core::record::Recording;
|
||||
use crate::serial_core::record::{FrameTiming, RecordedFrame};
|
||||
#[cfg(feature = "devkit")]
|
||||
use crate::devkit::{proto::SensorFrame, DevKitState};
|
||||
use anyhow::Result;
|
||||
use log::debug;
|
||||
use std::future::pending;
|
||||
#[cfg(feature = "devkit")]
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
@@ -17,14 +22,19 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::time::{self, Duration, MissedTickBehavior};
|
||||
use tokio_serial::SerialStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
#[cfg(feature = "devkit")]
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
const AUTO_SUB_INTERVAL: Duration = Duration::from_nanos(16_666_667);
|
||||
|
||||
pub enum PollMode<F> {
|
||||
Disable,
|
||||
Enabled(Box<dyn PollRequester<F>>),
|
||||
}
|
||||
|
||||
struct PendingSubFrame<F> {
|
||||
frame: F,
|
||||
values: Vec<i32>,
|
||||
}
|
||||
|
||||
pub trait SerialFrame: Clone + Send + 'static {
|
||||
fn dts_ms(&self) -> u64;
|
||||
|
||||
@@ -215,10 +225,15 @@ where
|
||||
it.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
it
|
||||
});
|
||||
let mut poll_sub_interval = time::interval(AUTO_SUB_INTERVAL);
|
||||
poll_sub_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
|
||||
let mut chart_state = HudChartState::new();
|
||||
let mut buffer = [0u8; 1024];
|
||||
let mut prune_interval = time::interval(Duration::from_millis(450));
|
||||
#[cfg(feature = "multi-dim")]
|
||||
let mut pzt_processor = PztProcessor::new();
|
||||
let mut pending_sub_frame: Option<PendingSubFrame<F>> = None;
|
||||
prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
@@ -246,6 +261,21 @@ where
|
||||
app.emit("hud_stream", packet)?;
|
||||
}
|
||||
}
|
||||
_ = poll_sub_interval.tick() => {
|
||||
if let Some(pending) = pending_sub_frame.take() {
|
||||
let display_values = build_display_values(
|
||||
&mut chart_state,
|
||||
pending.values.as_slice(),
|
||||
);
|
||||
|
||||
if let Some(packet) = pending
|
||||
.frame
|
||||
.to_hud_packet(&mut chart_state, display_values.as_deref())
|
||||
{
|
||||
app.emit("hud_stream", packet)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
read_result = port.read(&mut buffer) => {
|
||||
let n = read_result?;
|
||||
if n == 0 {
|
||||
@@ -266,25 +296,38 @@ where
|
||||
.await?
|
||||
.map(|vals| vals.into_iter().map(Into::into).collect::<Vec<i32>>());
|
||||
|
||||
let mut record = recording.lock().map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
|
||||
let mut record = recording
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
|
||||
record.push(RecordedFrame {
|
||||
timing: FrameTiming { pts_ms: None, dts_ms: frame.dts_ms() },
|
||||
timing: FrameTiming {
|
||||
pts_ms: None,
|
||||
dts_ms: frame.dts_ms(),
|
||||
},
|
||||
frame: frame.clone(),
|
||||
});
|
||||
drop(record);
|
||||
|
||||
let display_values = if let Some(vals) = decode_res.as_ref() {
|
||||
if let Some(vals) = decode_res {
|
||||
#[cfg(feature = "multi-dim")]
|
||||
{
|
||||
let pzt_values = vals.iter().map(|value| *value as f32).collect::<Vec<f32>>();
|
||||
if let Ok(angle) = pzt_processor.get_pzt_angle(&pzt_values) {
|
||||
// debug!("pzt angle: {:.2}", angle);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "devkit")]
|
||||
{
|
||||
let summary = vals.iter().copied().sum::<i32>();
|
||||
let force = raw_to_g1(summary as u32);
|
||||
chart_state.record_summary(force as f32);
|
||||
chart_state.record_pressure_matrix(vals.as_slice());
|
||||
#[cfg(feature = "devkit")]
|
||||
push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force);
|
||||
Some(vec![summary])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(packet) = frame.to_hud_packet(&mut chart_state, display_values.as_deref()) {
|
||||
pending_sub_frame = Some(PendingSubFrame {
|
||||
frame: frame.clone(),
|
||||
values: vals,
|
||||
});
|
||||
} else if let Some(packet) = frame.to_hud_packet(&mut chart_state, None) {
|
||||
app.emit("hud_stream", packet)?;
|
||||
}
|
||||
}
|
||||
@@ -294,6 +337,14 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_display_values(chart_state: &mut HudChartState, values: &[i32]) -> Option<Vec<i32>> {
|
||||
let summary = values.iter().copied().sum::<i32>();
|
||||
let force = raw_to_g1(summary as u32);
|
||||
chart_state.record_summary(force as f32);
|
||||
chart_state.record_pressure_matrix(values);
|
||||
Some(vec![summary])
|
||||
}
|
||||
|
||||
#[cfg(feature = "devkit")]
|
||||
fn push_devkit_frame(app: &AppHandle, values: &[i32], dts_ms: u64, resultant_force: f64) {
|
||||
let devkit_state = app.state::<DevKitState>();
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"nsis": {
|
||||
"installMode": "both",
|
||||
"displayLanguageSelector": false,
|
||||
"installerIcon": "icons/icon.ico"
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"template": "nsis/installer.nsi"
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
export let rangeLabel = "";
|
||||
export let rangeMinLabel = "";
|
||||
export let rangeMaxLabel = "";
|
||||
export let colorMapLabel = "";
|
||||
export let resetConfigLabel = "";
|
||||
export let applyLiveHint = "";
|
||||
export let matrixRows = 12;
|
||||
@@ -42,7 +41,6 @@
|
||||
export let rangeMax = DEFAULT_PRESSURE_RANGE_MAX;
|
||||
export let colorMapPreset: PressureColorMapPreset = "emerald";
|
||||
export let matrixDisplayMode: MatrixDisplayMode = "dots";
|
||||
export let colorMapOptions: HudColorMapOption[] = [];
|
||||
export let replaySectionLabel = "";
|
||||
export let replayPlayLabel = "";
|
||||
export let replayPauseLabel = "";
|
||||
@@ -56,6 +54,7 @@
|
||||
export let replayFileName = "";
|
||||
export let replayFrameInfo = "";
|
||||
export let showPrecisionTestPanel = false;
|
||||
export let sessionStartedAt: number = Date.now();
|
||||
|
||||
let stagePlaneEl: HTMLDivElement | undefined;
|
||||
let panelZoneEl: HTMLDivElement | undefined;
|
||||
@@ -195,6 +194,7 @@
|
||||
{rangeMax}
|
||||
{colorMapPreset}
|
||||
{matrixDisplayMode}
|
||||
{locale}
|
||||
showStatsPanel={true}
|
||||
/>
|
||||
{/key}
|
||||
@@ -225,6 +225,7 @@
|
||||
{rangeMax}
|
||||
{colorMapPreset}
|
||||
{matrixDisplayMode}
|
||||
{locale}
|
||||
showStatsPanel={true}
|
||||
/>
|
||||
{/key}
|
||||
@@ -246,9 +247,6 @@
|
||||
{rangeLabel}
|
||||
{rangeMinLabel}
|
||||
{rangeMaxLabel}
|
||||
{colorMapLabel}
|
||||
bind:colorMapPreset
|
||||
{colorMapOptions}
|
||||
resetLabel={resetConfigLabel}
|
||||
{applyLiveHint}
|
||||
on:close={() => dispatch("configclose")}
|
||||
@@ -267,7 +265,7 @@
|
||||
in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }}
|
||||
out:fly={{ x: -180, duration: 280, opacity: 0.06, easing: cubicIn }}
|
||||
>
|
||||
<SignalChart {panel} panelIndex={index} />
|
||||
<SignalChart {panel} panelIndex={index} {locale} />
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -281,6 +279,9 @@
|
||||
{summary}
|
||||
xValues={summary.xValues ?? null}
|
||||
yValues={summary.points}
|
||||
{locale}
|
||||
{sessionStartedAt}
|
||||
isRealtime={!replayHasData}
|
||||
side="left"
|
||||
panelIndex={leftPanels.length}
|
||||
/>
|
||||
@@ -298,7 +299,7 @@
|
||||
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
|
||||
out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }}
|
||||
>
|
||||
<SignalChart {panel} panelIndex={index} />
|
||||
<SignalChart {panel} panelIndex={index} {locale} />
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -312,6 +313,9 @@
|
||||
{summary}
|
||||
xValues={summary.xValues ?? null}
|
||||
yValues={summary.points}
|
||||
{locale}
|
||||
{sessionStartedAt}
|
||||
isRealtime={!replayHasData}
|
||||
side="right"
|
||||
panelIndex={rightPanels.length}
|
||||
/>
|
||||
@@ -396,7 +400,7 @@
|
||||
}
|
||||
|
||||
.stage-canvas-plane {
|
||||
--rail-width: clamp(17.5rem, 23vw, 21.5rem);
|
||||
--rail-width: clamp(20rem, 27vw, 26rem);
|
||||
--rail-edge-inset: clamp(0.35rem, 1vw, 0.9rem);
|
||||
--safe-gap: clamp(0.35rem, 0.9vw, 0.85rem);
|
||||
--panel-zone-top: clamp(6.4rem, 11.8vh, 8rem);
|
||||
@@ -754,7 +758,7 @@
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.stage-canvas-plane {
|
||||
--rail-width: clamp(14.2rem, 28vw, 16.4rem);
|
||||
--rail-width: clamp(17rem, 32vw, 22rem);
|
||||
--rail-edge-inset: clamp(0.25rem, 0.7vw, 0.55rem);
|
||||
--safe-gap: clamp(0.2rem, 0.75vw, 0.45rem);
|
||||
--panel-zone-top: clamp(6rem, 11.2vh, 7.2rem);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { DEFAULT_PRESSURE_RANGE_MAX, DEFAULT_PRESSURE_RANGE_MIN } from "$lib/config/pressure-range";
|
||||
import type { HudColorMapOption, PressureColorMapPreset } from "$lib/types/hud";
|
||||
|
||||
export let title = "";
|
||||
export let hint = "";
|
||||
@@ -11,15 +10,12 @@
|
||||
export let rangeLabel = "";
|
||||
export let rangeMinLabel = "";
|
||||
export let rangeMaxLabel = "";
|
||||
export let colorMapLabel = "";
|
||||
export let resetLabel = "";
|
||||
export let applyLiveHint = "";
|
||||
export let matrixRows = 12;
|
||||
export let matrixCols = 7;
|
||||
export let rangeMin = DEFAULT_PRESSURE_RANGE_MIN;
|
||||
export let rangeMax = DEFAULT_PRESSURE_RANGE_MAX;
|
||||
export let colorMapPreset: PressureColorMapPreset = "emerald";
|
||||
export let colorMapOptions: HudColorMapOption[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
close: void;
|
||||
@@ -78,24 +74,17 @@
|
||||
matrixCols = size;
|
||||
}
|
||||
|
||||
function applyColorMapPreset(id: PressureColorMapPreset): void {
|
||||
colorMapPreset = id;
|
||||
}
|
||||
|
||||
function resetDefaults(): void {
|
||||
matrixRows = 12;
|
||||
matrixCols = 7;
|
||||
rangeMin = DEFAULT_PRESSURE_RANGE_MIN;
|
||||
rangeMax = DEFAULT_PRESSURE_RANGE_MAX;
|
||||
colorMapPreset = "emerald";
|
||||
}
|
||||
|
||||
function handleSubmit(): void {
|
||||
dispatch("close");
|
||||
}
|
||||
|
||||
$: selectedColorMap = colorMapOptions.find((option) => option.id === colorMapPreset) ?? colorMapOptions[0];
|
||||
|
||||
$: {
|
||||
const nextRows = normalizeGridValue(matrixRows);
|
||||
if (nextRows !== matrixRows) {
|
||||
@@ -190,31 +179,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<div class="section-head">
|
||||
<p class="section-title">{colorMapLabel}</p>
|
||||
<p class="section-note">{selectedColorMap?.label ?? colorMapPreset}</p>
|
||||
</div>
|
||||
|
||||
<div class="palette-row" role="group" aria-label={colorMapLabel}>
|
||||
{#each colorMapOptions as option}
|
||||
<button
|
||||
type="button"
|
||||
class="palette-btn"
|
||||
class:is-active={colorMapPreset === option.id}
|
||||
on:click={() => applyColorMapPreset(option.id)}
|
||||
>
|
||||
<span
|
||||
class="palette-preview"
|
||||
style={`--palette-stop-0: ${option.previewStops[0]}; --palette-stop-1: ${option.previewStops[1]}; --palette-stop-2: ${option.previewStops[2]};`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="palette-name">{option.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="config-foot">
|
||||
<p class="live-note">{applyLiveHint}</p>
|
||||
<button type="button" class="reset-btn" on:click={resetDefaults}>{resetLabel}</button>
|
||||
@@ -327,15 +291,8 @@
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.palette-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.48rem;
|
||||
}
|
||||
|
||||
.preset-btn,
|
||||
.reset-btn,
|
||||
.palette-btn {
|
||||
.reset-btn {
|
||||
border: 1px solid rgb(var(--hud-border-rgb) / 0.28);
|
||||
border-radius: 999px;
|
||||
padding: 0.38rem 0.72rem;
|
||||
@@ -358,48 +315,6 @@
|
||||
box-shadow: inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.14), 0 0 16px rgb(var(--hud-glow-alt-rgb) / 0.14);
|
||||
}
|
||||
|
||||
.palette-btn {
|
||||
display: grid;
|
||||
gap: 0.34rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.52rem 0.56rem 0.58rem;
|
||||
border-radius: 0.74rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.palette-btn.is-active {
|
||||
border-color: rgb(var(--hud-lime-rgb) / 0.48);
|
||||
background: linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.96), rgb(var(--hud-surface-rgb) / 0.92));
|
||||
color: rgb(var(--hud-text-main-rgb) / 0.98);
|
||||
box-shadow: inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.14), 0 0 16px rgb(var(--hud-glow-alt-rgb) / 0.14);
|
||||
}
|
||||
|
||||
.palette-preview {
|
||||
display: block;
|
||||
inline-size: 100%;
|
||||
block-size: 0.74rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
var(--palette-stop-0) 0%,
|
||||
var(--palette-stop-1) 52%,
|
||||
var(--palette-stop-2) 100%
|
||||
),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.08), transparent 55%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.1),
|
||||
0 0 12px rgb(0 0 0 / 0.14);
|
||||
}
|
||||
|
||||
.palette-name {
|
||||
color: inherit;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -457,9 +372,5 @@
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.palette-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +2,6 @@
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let running = false;
|
||||
export let port = 50051;
|
||||
export let framesSent = 0;
|
||||
export let filterLiftEnabled = true;
|
||||
export let saveAsXlsx = false;
|
||||
export let locale: "zh-CN" | "en-US" = "zh-CN";
|
||||
@@ -22,37 +20,37 @@
|
||||
togglexlsx: void;
|
||||
}>();
|
||||
|
||||
$: labels = locale === "zh-CN"
|
||||
$: labels =
|
||||
locale === "zh-CN"
|
||||
? {
|
||||
title: "DevKit 配置",
|
||||
title: "开发工具配置",
|
||||
close: "关闭",
|
||||
status: "状态",
|
||||
connected: "已连接",
|
||||
disconnected: "未连接",
|
||||
port: "端口",
|
||||
framesSent: "已发送帧",
|
||||
filterLift: "导出过滤抬起",
|
||||
filterLiftHint: "导出 CSV 后自动调用 Python 做梯度过滤,过滤掉抬起的小值数据",
|
||||
saveXlsx: "以 xlsx 保存",
|
||||
saveXlsxHint: "Python 处理后输出 xlsx 格式并删除源 CSV 文件",
|
||||
filterLift: "导出后过滤抬起",
|
||||
filterLiftHint: "导出 CSV 时自动过滤掉抬起阶段的小值数据。",
|
||||
saveXlsx: "保存为 xlsx",
|
||||
saveXlsxHint: "将导出文件转换为 xlsx 格式。",
|
||||
lastResult: "最近一次处理",
|
||||
output: "输出文件",
|
||||
groups: "分组数",
|
||||
mean: "均值",
|
||||
threshold: "阈值",
|
||||
rows: "行数",
|
||||
rows: "总行数",
|
||||
kept: "保留行数",
|
||||
rowsFlow: "行数变化"
|
||||
}
|
||||
: {
|
||||
title: "DevKit Config",
|
||||
close: "Close",
|
||||
status: "Status",
|
||||
connected: "Connected",
|
||||
disconnected: "Disconnected",
|
||||
port: "Port",
|
||||
framesSent: "Frames sent",
|
||||
filterLift: "Filter lift on export",
|
||||
filterLiftHint: "After CSV export, automatically call Python to filter out small values",
|
||||
filterLiftHint: "Automatically filter out small values from lift-off phases during CSV export.",
|
||||
saveXlsx: "Save as xlsx",
|
||||
saveXlsxHint: "Python outputs xlsx format and deletes the source CSV file",
|
||||
saveXlsxHint: "Convert exported file to xlsx format.",
|
||||
lastResult: "Last process",
|
||||
output: "Output",
|
||||
groups: "Groups",
|
||||
@@ -60,13 +58,14 @@
|
||||
threshold: "Threshold",
|
||||
rows: "Rows",
|
||||
kept: "Kept rows",
|
||||
rowsFlow: "Rows flow"
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="dk-panel">
|
||||
<header class="dk-head">
|
||||
<h3 class="dk-title">{labels.title}</h3>
|
||||
<button type="button" class="dk-close" on:click={() => dispatch("close")} aria-label="Close">
|
||||
<button type="button" class="dk-close" on:click={() => dispatch("close")} aria-label={labels.close}>
|
||||
<span></span><span></span>
|
||||
</button>
|
||||
</header>
|
||||
@@ -77,18 +76,6 @@
|
||||
<span class="dk-label">{labels.status}</span>
|
||||
<span class="dk-value">{running ? labels.connected : labels.disconnected}</span>
|
||||
</div>
|
||||
{#if running}
|
||||
<div class="dk-info-grid">
|
||||
<div class="dk-info">
|
||||
<span class="dk-info-label">{labels.port}</span>
|
||||
<span class="dk-info-value">:{port}</span>
|
||||
</div>
|
||||
<div class="dk-info">
|
||||
<span class="dk-info-label">{labels.framesSent}</span>
|
||||
<span class="dk-info-value">{framesSent}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="dk-section">
|
||||
@@ -132,8 +119,8 @@
|
||||
<span class="dk-result-value">{lastProcessResult.threshold.toFixed(3)}</span>
|
||||
</div>
|
||||
<div class="dk-result-item">
|
||||
<span class="dk-result-label">{labels.rows}</span>
|
||||
<span class="dk-result-value">{lastProcessResult.rowsTotal} → {lastProcessResult.rowsKept}</span>
|
||||
<span class="dk-result-label">{labels.rowsFlow}</span>
|
||||
<span class="dk-result-value">{lastProcessResult.rowsTotal} -> {lastProcessResult.rowsKept}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -236,30 +223,6 @@
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.dk-info-grid {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dk-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.dk-info-label {
|
||||
color: rgb(var(--hud-text-dim-rgb) / 0.7);
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dk-info-value {
|
||||
color: rgb(var(--hud-text-main-rgb) / 0.94);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dk-toggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
export let matrixDisplayMode: MatrixDisplayMode = "dots";
|
||||
export let summary: HudSummary | null = null;
|
||||
export let showStatsPanel = true;
|
||||
export let locale: "zh-CN" | "en-US" = "zh-CN";
|
||||
|
||||
let viewerEl: HTMLDivElement | undefined;
|
||||
let canvasEl: HTMLCanvasElement | undefined;
|
||||
@@ -131,8 +132,13 @@
|
||||
$: resolvedRangeMin = resolvedRange.min;
|
||||
$: resolvedRangeMax = resolvedRange.max;
|
||||
$: matrixLayout = buildMatrixLayout(resolvedMatrixRows, resolvedMatrixCols);
|
||||
$: statsModeLabel = matrixDisplayMode === "dots" ? "dot pulse" : "numeric pulse";
|
||||
$: statsNote = `${resolvedMatrixRows}x${resolvedMatrixCols} / force range ${resolvedRangeMin}-${resolvedRangeMax} / ${statsModeLabel}`;
|
||||
$: statsModeLabel = matrixDisplayMode === "dots"
|
||||
? (locale === "zh-CN" ? "点阵脉冲" : "dot pulse")
|
||||
: (locale === "zh-CN" ? "数字脉冲" : "numeric pulse");
|
||||
$: statsNote = `${resolvedMatrixRows}x${resolvedMatrixCols} / ${locale === "zh-CN" ? "力量范围" : "force range"} ${resolvedRangeMin}-${resolvedRangeMax} / ${statsModeLabel}`;
|
||||
$: viewerI18n = locale === "zh-CN"
|
||||
? { title: "合力", current: "当前合力", max: "最大合力", min: "最小合力" }
|
||||
: { title: "Resultant Force", current: "Current RF", max: "Max RF", min: "Min RF" };
|
||||
|
||||
function formatForceStat(value: number | null): string {
|
||||
if (value == null || !Number.isFinite(value)) {
|
||||
@@ -660,18 +666,18 @@
|
||||
{#if showStatsPanel}
|
||||
<div class="viewer-controls">
|
||||
<section class="stats-panel" aria-label="Pressure Summary">
|
||||
<p class="stats-label">Resultant Force</p>
|
||||
<p class="stats-label">{viewerI18n.title}</p>
|
||||
<div class="stats-grid">
|
||||
<article class="stats-card stats-card-wide">
|
||||
<span class="stats-key">Current RF</span>
|
||||
<span class="stats-key">{viewerI18n.current}</span>
|
||||
<strong class="stats-value">{formatForceStat(stats.current)}</strong>
|
||||
</article>
|
||||
<article class="stats-card">
|
||||
<span class="stats-key">Max RF</span>
|
||||
<span class="stats-key">{viewerI18n.max}</span>
|
||||
<strong class="stats-value">{formatForceStat(stats.max)}</strong>
|
||||
</article>
|
||||
<article class="stats-card">
|
||||
<span class="stats-key">Min RF</span>
|
||||
<span class="stats-key">{viewerI18n.min}</span>
|
||||
<strong class="stats-value">{formatForceStat(stats.min)}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
|
||||
export let panel: HudSignalPanel;
|
||||
export let panelIndex = 0;
|
||||
export let locale: "zh-CN" | "en-US" = "zh-CN";
|
||||
|
||||
$: signalI18n = locale === "zh-CN"
|
||||
? { now: "当前", max: "最大", min: "最小", total: "合计" }
|
||||
: { now: "Now", max: "Max", min: "Min", total: "TOTAL" };
|
||||
|
||||
const viewportWidth = 100;
|
||||
const viewportHeight = 36;
|
||||
@@ -110,7 +115,7 @@
|
||||
|
||||
<div class="icon-layer" aria-hidden="true">
|
||||
{#each panel.icons as icon (icon.id)}
|
||||
<span class="icon-chip tone-{icon.tone}">{icon.label}</span>
|
||||
<span class="icon-chip tone-{icon.tone}">{icon.label === "TOTAL" ? signalI18n.total : icon.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
@@ -136,17 +141,17 @@
|
||||
<footer class="panel-foot">
|
||||
<p class="foot-item">
|
||||
<span class="dot tone-cyan"></span>
|
||||
<span class="metric-label">Now</span>
|
||||
<span class="metric-label">{signalI18n.now}</span>
|
||||
<span class="value">{latestValue}</span>
|
||||
</p>
|
||||
<p class="foot-item">
|
||||
<span class="dot tone-lime"></span>
|
||||
<span class="metric-label">Max</span>
|
||||
<span class="metric-label">{signalI18n.max}</span>
|
||||
<span class="value">{maxValue}</span>
|
||||
</p>
|
||||
<p class="foot-item">
|
||||
<span class="dot tone-orange"></span>
|
||||
<span class="metric-label">Min</span>
|
||||
<span class="metric-label">{signalI18n.min}</span>
|
||||
<span class="value">{minValue}</span>
|
||||
</p>
|
||||
</footer>
|
||||
@@ -158,7 +163,7 @@
|
||||
--enter-ms: 1800ms;
|
||||
--fade-ms: 1000ms;
|
||||
overflow: hidden;
|
||||
inline-size: min(100%, clamp(16.8rem, 23vw, 22rem));
|
||||
inline-size: min(100%, clamp(19rem, 27vw, 26rem));
|
||||
aspect-ratio: 1.44 / 1;
|
||||
min-block-size: 11.8rem;
|
||||
justify-self: start;
|
||||
@@ -388,7 +393,7 @@
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.signal-panel {
|
||||
inline-size: min(100%, clamp(14rem, 30vw, 17rem));
|
||||
inline-size: min(100%, clamp(16rem, 32vw, 21rem));
|
||||
aspect-ratio: 1.5 / 1;
|
||||
min-block-size: 10.1rem;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { HudSummary } from "$lib/types/hud";
|
||||
|
||||
export let summary: HudSummary;
|
||||
@@ -6,6 +7,25 @@
|
||||
export let panelIndex = 0;
|
||||
export let xValues: number[] | null = null;
|
||||
export let yValues: number[] | null = null;
|
||||
export let locale: "zh-CN" | "en-US" = "zh-CN";
|
||||
export let sessionStartedAt: number = Date.now();
|
||||
export let isRealtime = false;
|
||||
|
||||
let currentTimeSeconds = 0;
|
||||
let timerId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMount(() => {
|
||||
timerId = setInterval(() => {
|
||||
currentTimeSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
|
||||
}, 200);
|
||||
return () => {
|
||||
if (timerId != null) clearInterval(timerId);
|
||||
};
|
||||
});
|
||||
|
||||
$: i18n = locale === "zh-CN"
|
||||
? { now: "当前", min: "最小", max: "最大", waiting: "等待数据" }
|
||||
: { now: "Now", min: "Min", max: "Max", waiting: "Waiting" };
|
||||
|
||||
const viewportWidth = 120;
|
||||
const viewportHeight = 48;
|
||||
@@ -50,7 +70,12 @@
|
||||
}
|
||||
|
||||
if (axis === "x") {
|
||||
return String(Math.round(value));
|
||||
if (value < 60) {
|
||||
return `${value.toFixed(1)}s`;
|
||||
}
|
||||
const mins = Math.floor(value / 60);
|
||||
const secs = value - mins * 60;
|
||||
return `${mins}:${secs.toFixed(0).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
return `${Math.round(value)} N`;
|
||||
@@ -104,14 +129,51 @@
|
||||
return [];
|
||||
}
|
||||
|
||||
let previousX = 0;
|
||||
|
||||
return rawYValues.map((rawY, index) => {
|
||||
const x = rawXValues[index];
|
||||
const y = Number.isFinite(rawY) ? Number(rawY) : 0;
|
||||
const resolvedX = Number.isFinite(x) ? Number(x) : index + 1;
|
||||
return { x: resolvedX, y };
|
||||
const fallbackX = index === 0 ? 0 : previousX + 1;
|
||||
const resolvedX = Number.isFinite(x) ? Number(x) : fallbackX;
|
||||
const normalizedX = index === 0 ? resolvedX : Math.max(resolvedX, previousX);
|
||||
previousX = normalizedX;
|
||||
return { x: normalizedX, y };
|
||||
});
|
||||
}
|
||||
|
||||
function resolveXScaleBounds(
|
||||
samples: CurveSample[],
|
||||
currentSeconds: number,
|
||||
realtime: boolean
|
||||
): { min: number; max: number } {
|
||||
if (samples.length === 0) {
|
||||
return { min: 0, max: 1 };
|
||||
}
|
||||
|
||||
const values = samples.map((sample) => sample.x);
|
||||
const dataBounds = resolveBounds(values);
|
||||
|
||||
if (!realtime) {
|
||||
return dataBounds;
|
||||
}
|
||||
|
||||
const firstX = samples[0].x;
|
||||
const lastX = samples[samples.length - 1].x;
|
||||
const axisMax = Math.max(lastX, currentSeconds);
|
||||
const positiveDiffs = samples
|
||||
.slice(1)
|
||||
.map((sample, index) => sample.x - samples[index].x)
|
||||
.filter((diff) => diff > 0);
|
||||
const averageSpacing =
|
||||
positiveDiffs.length > 0 ? positiveDiffs.reduce((sum, diff) => sum + diff, 0) / positiveDiffs.length : 1;
|
||||
const dataSpan = Math.max(lastX - firstX, 0);
|
||||
const windowSpan = Math.max(dataSpan, averageSpacing * Math.max(samples.length - 1, 1), 1);
|
||||
const axisMin = Math.max(0, axisMax - windowSpan);
|
||||
|
||||
return resolveBounds([axisMin, axisMax]);
|
||||
}
|
||||
|
||||
function convertPoints(
|
||||
samples: CurveSample[],
|
||||
xBounds: { min: number; max: number },
|
||||
@@ -146,14 +208,14 @@
|
||||
}));
|
||||
}
|
||||
|
||||
function buildXAxisTicks(samples: CurveSample[], xScaleBounds: { min: number; max: number }): AxisTick[] {
|
||||
if (!samples.length) {
|
||||
function buildXAxisTicks(xScaleBounds: { min: number; max: number }): AxisTick[] {
|
||||
if (!Number.isFinite(xScaleBounds.min) || !Number.isFinite(xScaleBounds.max)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const first = samples[0].x;
|
||||
const middle = samples[Math.floor((samples.length - 1) / 2)].x;
|
||||
const last = samples[samples.length - 1].x;
|
||||
const first = xScaleBounds.min;
|
||||
const middle = xScaleBounds.min + (xScaleBounds.max - xScaleBounds.min) / 2;
|
||||
const last = xScaleBounds.max;
|
||||
const tickValues = [first, middle, last];
|
||||
return tickValues.map((value) => ({
|
||||
value,
|
||||
@@ -185,9 +247,16 @@
|
||||
|
||||
$: sourceYValues = yValues && yValues.length ? yValues : summary.points;
|
||||
$: sourceXValues = xValues && xValues.length ? xValues : summary.xValues ?? [];
|
||||
$: samples = buildSamples(sourceYValues, sourceXValues);
|
||||
$: samples = (() => {
|
||||
const base = buildSamples(sourceYValues, sourceXValues);
|
||||
if (isRealtime && base.length > 0 && currentTimeSeconds > 0) {
|
||||
const lastSample = base[base.length - 1];
|
||||
base[base.length - 1] = { ...lastSample, x: Math.max(lastSample.x, currentTimeSeconds) };
|
||||
}
|
||||
return base;
|
||||
})();
|
||||
$: sampleCount = samples.length;
|
||||
$: xScaleBounds = resolveBounds(samples.map((sample) => sample.x));
|
||||
$: xScaleBounds = resolveXScaleBounds(samples, currentTimeSeconds, isRealtime);
|
||||
$: yScaleBounds = fixedYBounds;
|
||||
$: xDataBounds = resolveDataBounds(samples.map((sample) => sample.x));
|
||||
$: yDataBounds = resolveDataBounds(samples.map((sample) => sample.y));
|
||||
@@ -196,7 +265,7 @@
|
||||
$: areaPath = createAreaPath(plotPoints);
|
||||
$: lastPoint = plotPoints.length > 0 ? plotPoints[plotPoints.length - 1] : null;
|
||||
$: yAxisTicks = sampleCount > 0 ? buildYAxisTicks(yScaleBounds, yDataBounds) : [];
|
||||
$: xAxisTicks = sampleCount > 0 ? buildXAxisTicks(samples, xScaleBounds) : [];
|
||||
$: xAxisTicks = sampleCount > 0 ? buildXAxisTicks(xScaleBounds) : [];
|
||||
$: latestValue = formatValue(summary.latest);
|
||||
$: minValue = formatValue(summary.min);
|
||||
$: maxValue = formatValue(summary.max);
|
||||
@@ -270,7 +339,7 @@
|
||||
|
||||
{#if sampleCount === 0}
|
||||
<div class="empty-state">
|
||||
<span>Waiting</span>
|
||||
<span>{i18n.waiting}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -278,17 +347,17 @@
|
||||
<footer class="panel-foot">
|
||||
<p class="foot-item">
|
||||
<span class="dot tone-cyan"></span>
|
||||
<span class="metric-text">Now</span>
|
||||
<span class="metric-text">{i18n.now}</span>
|
||||
<span class="value">{latestValue}</span>
|
||||
</p>
|
||||
<p class="foot-item">
|
||||
<span class="dot tone-lime"></span>
|
||||
<span class="metric-text">Min</span>
|
||||
<span class="metric-text">{i18n.min}</span>
|
||||
<span class="value">{minValue}</span>
|
||||
</p>
|
||||
<p class="foot-item">
|
||||
<span class="dot tone-orange"></span>
|
||||
<span class="metric-text">Max</span>
|
||||
<span class="metric-text">{i18n.max}</span>
|
||||
<span class="value">{maxValue}</span>
|
||||
</p>
|
||||
</footer>
|
||||
@@ -300,12 +369,10 @@
|
||||
--enter-ms: 1800ms;
|
||||
--fade-ms: 1000ms;
|
||||
overflow: hidden;
|
||||
inline-size: min(100%, clamp(29rem, 38vw, 37rem));
|
||||
aspect-ratio: 1.42 / 1;
|
||||
min-block-size: 20.5rem;
|
||||
inline-size: min(100%, clamp(34rem, 44vw, 44rem));
|
||||
justify-self: start;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 0.68rem;
|
||||
padding: 0.88rem 0.96rem 1rem;
|
||||
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.42);
|
||||
@@ -404,6 +471,7 @@
|
||||
.chart-stage {
|
||||
position: relative;
|
||||
block-size: clamp(12rem, 15.5vw, 15rem);
|
||||
min-block-size: 5rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.32);
|
||||
border-radius: 0.62rem;
|
||||
@@ -474,25 +542,29 @@
|
||||
|
||||
.panel-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.8rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.foot-item {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
gap: 0.22rem;
|
||||
color: rgb(var(--hud-text-main-rgb) / 0.9);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-text {
|
||||
color: rgb(var(--hud-text-dim-rgb) / 0.82);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
@@ -519,16 +591,18 @@
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.signal-panel {
|
||||
inline-size: min(100%, clamp(24rem, 36vw, 31rem));
|
||||
aspect-ratio: 1.48 / 1;
|
||||
min-block-size: 17rem;
|
||||
inline-size: min(100%, clamp(28rem, 40vw, 38rem));
|
||||
}
|
||||
|
||||
.chart-stage {
|
||||
block-size: clamp(10rem, 13vw, 12rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 900px) {
|
||||
.signal-panel {
|
||||
inline-size: min(100%, clamp(24rem, 33vw, 30rem));
|
||||
min-block-size: 16.8rem;
|
||||
inline-size: min(100%, clamp(28rem, 38vw, 36rem));
|
||||
padding: 0.7rem 0.76rem 0.8rem;
|
||||
}
|
||||
|
||||
.chart-stage {
|
||||
@@ -538,46 +612,31 @@
|
||||
|
||||
@media (max-height: 760px) {
|
||||
.signal-panel {
|
||||
inline-size: min(100%, clamp(21rem, 29vw, 26rem));
|
||||
min-block-size: 14.4rem;
|
||||
padding: 0.7rem 0.76rem 0.8rem;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
margin-block-start: 0.28rem;
|
||||
inline-size: min(100%, clamp(24rem, 34vw, 30rem));
|
||||
padding: 0.62rem 0.68rem 0.72rem;
|
||||
gap: 0.48rem;
|
||||
}
|
||||
|
||||
.chart-stage {
|
||||
block-size: clamp(8.3rem, 9.6vw, 9.8rem);
|
||||
block-size: clamp(8rem, 9.5vw, 9.8rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 680px) {
|
||||
.signal-panel {
|
||||
inline-size: min(100%, clamp(18.5rem, 24vw, 22.5rem));
|
||||
min-block-size: 12.4rem;
|
||||
padding: 0.62rem 0.66rem 0.68rem;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
margin-block-end: 0.26rem;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
margin-block-start: 0.18rem;
|
||||
gap: 0.56rem;
|
||||
inline-size: min(100%, clamp(20rem, 28vw, 26rem));
|
||||
padding: 0.52rem 0.58rem 0.6rem;
|
||||
gap: 0.36rem;
|
||||
}
|
||||
|
||||
.chart-stage {
|
||||
block-size: clamp(7rem, 7.8vw, 8rem);
|
||||
block-size: clamp(6.5rem, 8vw, 7.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.signal-panel {
|
||||
inline-size: 100%;
|
||||
aspect-ratio: 1.7 / 1;
|
||||
min-block-size: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -260,9 +260,17 @@
|
||||
rowsKept: number;
|
||||
} | null = null;
|
||||
let devkitStatusTimer: number | null = null;
|
||||
let sessionStartedAt: number = Date.now();
|
||||
|
||||
$: uiCopy = copyByLocale[locale];
|
||||
$: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen, isPrecisionTestOpen);
|
||||
$: configLinks = buildConfigLinks(
|
||||
locale,
|
||||
activeConfigLinkId,
|
||||
isConfigPanelOpen,
|
||||
isPrecisionTestOpen,
|
||||
devkitEnabled,
|
||||
isDevKitConfigOpen
|
||||
);
|
||||
$: leftSignalPanels = signalPanels.filter((panel) => panel.side === "left");
|
||||
$: rightSignalPanels = signalPanels.filter((panel) => panel.side === "right");
|
||||
$: rangeTicks = buildRangeTicks(rangeMin, rangeMax);
|
||||
@@ -718,12 +726,12 @@
|
||||
const safeIndex = clamp(index, 0, replayFrames.length - 1);
|
||||
const startIndex = Math.max(0, safeIndex - summaryPointsPerSeries + 1);
|
||||
const points: number[] = [];
|
||||
const frameIds: number[] = [];
|
||||
const xSeconds: number[] = [];
|
||||
for (let cursor = startIndex; cursor <= safeIndex; cursor += 1) {
|
||||
points.push(replayFrameTotal(replayFrames[cursor]));
|
||||
frameIds.push(cursor + 1);
|
||||
xSeconds.push(replayFrames[cursor].dtsMs / 1000);
|
||||
}
|
||||
return buildSummary(points, frameIds);
|
||||
return buildSummary(points, xSeconds);
|
||||
}
|
||||
|
||||
function applyReplayFrame(index: number): void {
|
||||
@@ -952,10 +960,11 @@
|
||||
? summaryValue.points[summaryValue.points.length - 1]
|
||||
: randomBetween(280, 1600);
|
||||
const next = Math.round(clamp(previous + randomBetween(-160, 160), 120, 2400) * 10) / 10;
|
||||
const nowSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
|
||||
const previousXValues =
|
||||
summaryValue.xValues && summaryValue.xValues.length === summaryValue.points.length
|
||||
? summaryValue.xValues
|
||||
: summaryValue.points.map((_, index) => index + 1);
|
||||
: summaryValue.points.map((_, index) => nowSeconds);
|
||||
const points =
|
||||
summaryValue.points.length >= summaryPointsPerSeries
|
||||
? summaryValue.points.slice(1)
|
||||
@@ -964,7 +973,7 @@
|
||||
previousXValues.length >= summaryPointsPerSeries ? previousXValues.slice(1) : previousXValues.slice();
|
||||
|
||||
points.push(next);
|
||||
xValues.push((xValues[xValues.length - 1] ?? 0) + 1);
|
||||
xValues.push(nowSeconds);
|
||||
return buildSummary(points, xValues);
|
||||
}
|
||||
|
||||
@@ -977,7 +986,17 @@
|
||||
return;
|
||||
}
|
||||
signalPanels = showSignalPanels ? packet.panels : buildInactivePanels();
|
||||
if (packet.summary.points.length > 0) {
|
||||
const nowSeconds = Math.round((Date.now() - sessionStartedAt) / 100) / 10;
|
||||
const pointCount = packet.summary.points.length;
|
||||
const spacing =
|
||||
pointCount > 1 ? Math.min(1.2, nowSeconds / Math.max(pointCount - 1, 1)) : 0;
|
||||
const startX = Math.max(0, nowSeconds - spacing * Math.max(pointCount - 1, 0));
|
||||
const xValues = packet.summary.points.map((_, index) => Math.round((startX + index * spacing) * 10) / 10);
|
||||
summary = { ...packet.summary, xValues };
|
||||
} else {
|
||||
summary = packet.summary;
|
||||
}
|
||||
pressureMatrix = packet.pressureMatrix;
|
||||
hasSignalData = signalPanels.length > 0 || packet.summary.points.length > 0;
|
||||
}
|
||||
@@ -1015,24 +1034,25 @@
|
||||
currentLocale: LocaleCode,
|
||||
activeId: string,
|
||||
isSettingsOpen: boolean,
|
||||
isPrecisionOpen: boolean
|
||||
isPrecisionOpen: boolean,
|
||||
isDevKitEnabled: boolean,
|
||||
isDevKitOpen: boolean
|
||||
): HudConfigLink[] {
|
||||
const labels =
|
||||
currentLocale === "zh-CN"
|
||||
? {
|
||||
streamOn: "打开",
|
||||
streamOff: "关闭",
|
||||
calibrate: "校准",
|
||||
precisionTest: "游戏",
|
||||
settings: "参数"
|
||||
}
|
||||
: {
|
||||
streamOn: "Open",
|
||||
streamOff: "Close",
|
||||
calibrate: "Calib",
|
||||
precisionTest: "Game",
|
||||
settings: "Setup"
|
||||
};
|
||||
const devkitLabel = currentLocale === "zh-CN" ? "开发工具" : "DevKit";
|
||||
|
||||
const links: HudConfigLink[] = [
|
||||
{
|
||||
@@ -1047,12 +1067,6 @@
|
||||
tone: "orange",
|
||||
active: activeId === "stream-off"
|
||||
},
|
||||
{
|
||||
id: "calibrate",
|
||||
label: labels.calibrate,
|
||||
tone: "cyan",
|
||||
active: activeId === "calibrate"
|
||||
},
|
||||
{
|
||||
id: "precision-test",
|
||||
label: labels.precisionTest,
|
||||
@@ -1067,12 +1081,12 @@
|
||||
}
|
||||
];
|
||||
|
||||
if (devkitEnabled) {
|
||||
if (isDevKitEnabled) {
|
||||
links.push({
|
||||
id: "devkit",
|
||||
label: "DevKit",
|
||||
label: devkitLabel,
|
||||
tone: "cyan",
|
||||
active: isDevKitConfigOpen
|
||||
active: isDevKitOpen
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1195,6 +1209,12 @@
|
||||
connectionNotice = "";
|
||||
}
|
||||
|
||||
$: if (updateNoticeVisible && pendingUpdate && !updateInstallBusy) {
|
||||
connectionNotice = locale === "zh-CN"
|
||||
? `发现新版本 ${pendingUpdate.version},是否现在下载并安装?`
|
||||
: `Version ${pendingUpdate.version} is available. Download and install now?`;
|
||||
}
|
||||
|
||||
function handleLocaleChange(event: CustomEvent<LocaleCode>): void {
|
||||
locale = event.detail;
|
||||
}
|
||||
@@ -1726,6 +1746,7 @@
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let unlistenHudStream: UnlistenFn | null = null;
|
||||
let unlistenDevkitPztAngle: UnlistenFn | null = null;
|
||||
let stopMockFeed: (() => void) | null = null;
|
||||
|
||||
void ensureDefaultWindowSize();
|
||||
@@ -1749,6 +1770,23 @@
|
||||
.catch((error) => {
|
||||
console.error("Failed to listen for hud_stream:", error);
|
||||
});
|
||||
void listen<{ seq: number; timestampMs: number; dtsMs: number; angle: number }>(
|
||||
"devkit_pzt_angle",
|
||||
(event) => {
|
||||
console.log("[devkit_pzt_angle]", event.payload);
|
||||
}
|
||||
)
|
||||
.then((unlisten) => {
|
||||
if (disposed) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
|
||||
unlistenDevkitPztAngle = unlisten;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to listen for devkit_pzt_angle:", error);
|
||||
});
|
||||
} else {
|
||||
stopMockFeed = startMockFeed(applyPacket);
|
||||
}
|
||||
@@ -1758,6 +1796,7 @@
|
||||
pauseReplayPlayback();
|
||||
stopMockFeed?.();
|
||||
unlistenHudStream?.();
|
||||
unlistenDevkitPztAngle?.();
|
||||
if (devkitStatusTimer != null) {
|
||||
window.clearInterval(devkitStatusTimer);
|
||||
devkitStatusTimer = null;
|
||||
@@ -1849,8 +1888,6 @@
|
||||
rangeLabel={uiCopy.rangeLabel}
|
||||
rangeMinLabel={uiCopy.rangeMinLabel}
|
||||
rangeMaxLabel={uiCopy.rangeMaxLabel}
|
||||
colorMapLabel={uiCopy.colorMapLabel}
|
||||
{colorMapOptions}
|
||||
replaySectionLabel={uiCopy.replaySectionLabel}
|
||||
replayPlayLabel={uiCopy.replayPlayLabel}
|
||||
replayPauseLabel={uiCopy.replayPauseLabel}
|
||||
@@ -1863,6 +1900,7 @@
|
||||
{replayProgress}
|
||||
{replayFileName}
|
||||
{replayFrameInfo}
|
||||
{sessionStartedAt}
|
||||
resetConfigLabel={uiCopy.resetConfigLabel}
|
||||
applyLiveHint={uiCopy.applyLiveHint}
|
||||
leftPanels={leftSignalPanels}
|
||||
@@ -1880,7 +1918,7 @@
|
||||
>
|
||||
{#if !isPrecisionTestOpen}
|
||||
<section class="range-scale" aria-label="Signal Range">
|
||||
<p class="range-label">Range</p>
|
||||
<p class="range-label">{locale === "zh-CN" ? "范围" : "Range"}</p>
|
||||
<div class="range-track">
|
||||
{#each rangeTicks as tick}
|
||||
<span class="range-tick">{tick}</span>
|
||||
@@ -1919,12 +1957,10 @@
|
||||
/>
|
||||
|
||||
{#if isDevKitConfigOpen && devkitEnabled}
|
||||
<div class="devkit-overlay" role="dialog" aria-label="DevKit Config">
|
||||
<div class="devkit-overlay" role="dialog" aria-label={locale === "zh-CN" ? "开发工具配置" : "DevKit Config"}>
|
||||
<div class="devkit-float">
|
||||
<DevKitConfigPanel
|
||||
running={devkitRunning}
|
||||
port={devkitPort}
|
||||
framesSent={devkitFramesSent}
|
||||
filterLiftEnabled={devkitFilterLift}
|
||||
saveAsXlsx={devkitSaveXlsx}
|
||||
locale={locale}
|
||||
|
||||
Reference in New Issue
Block a user