diff --git a/.gitignore b/.gitignore index f355e45..a8b58ae 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ venv/ **/outputs/** !**/.gitkeep **/preference.json -apps/customer/preference.json +apps/studio/preference.json # Tooling scratch (local pip/pytest caches) .piptmp/ diff --git a/README.md b/README.md index c0d2f48..453144e 100644 --- a/README.md +++ b/README.md @@ -4,35 +4,35 @@ Desktop applications that consume the Orisys SDK. | App | Status | |-----|--------| -| `apps/customer` | Scaffold shell (theme / locale / prefs / logging) | +| `apps/studio` | Scaffold shell (theme / locale / prefs / logging) | | `apps/internal` | Stub for future calibration & automated testing | ## Layout - `shared/` — common paths, prefs, theme, i18n, logging -- `apps/customer/config/` — locales, default prefs, editable `sensors/` copies -- `apps/customer/outputs/` — generated logs and calibration exports (gitignored) -- `build/` — Windows PyInstaller packaging (`build/customer/build.ps1`) +- `apps/studio/config/` — locales, default prefs, editable `sensors/` copies +- `apps/studio/outputs/` — generated logs and calibration exports (gitignored) +- `build/` — Windows PyInstaller packaging (`build/studio/build.ps1`) -## Run (customer shell) +## Run (studio shell) ```powershell cd D:\repo\orisys-app -python -m pip install -e .\shared -e .\apps\customer -python .\apps\customer\run.py +python -m pip install -e .\shared -e .\apps\studio +python .\apps\studio\run.py ``` Or without install: ```powershell -$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\customer\src" -python D:\repo\orisys-app\apps\customer\run.py +$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\studio\src" +python D:\repo\orisys-app\apps\studio\run.py ``` ### Local `orisys` SDK `orisys_app_shared.force` imports `orisys.calibration` at module scope, so the -customer app cannot start against an SDK build that predates that module +studio app cannot start against an SDK build that predates that module (the published 0.5.1 wheel does not include it). When working from a repo checkout, install the SDK from source instead of from an index: @@ -51,13 +51,13 @@ Both tools render the real main window with the offscreen Qt platform — no display, camera or sensor hardware required: ```powershell -python .\apps\customer\tools\ui_check.py # layout/theme assertions -python .\apps\customer\tools\ui_snapshot.py --theme light --demo ` +python .\apps\studio\tools\ui_check.py # layout/theme assertions +python .\apps\studio\tools\ui_snapshot.py --theme light --demo ` --sensor finger --panels cal,3d --size 1600x900 --out shot.png ``` `ui_check.py` needs only the runtime dependencies. The `pytest` suite in -`shared/tests` and `apps/customer/tests` covers the same ground plus locale and +`shared/tests` and `apps/studio/tests` covers the same ground plus locale and preference-schema parity: ```powershell @@ -70,7 +70,7 @@ at `C:\Windows\Fonts`; both tools set it automatically. `ui_check.py` also runs the palette rules (WCAG contrast, colour separation, ramp monotonicity) that guard the brand tokens; see -[`apps/customer/docs/ui-palette.md`](apps/customer/docs/ui-palette.md) for the +[`apps/studio/docs/ui-palette.md`](apps/studio/docs/ui-palette.md) for the colour system, its relationship to the UI design spec, and every deliberate deviation from it. @@ -83,9 +83,9 @@ Dark mode is the default. Logos currently sit on a black tile; a transparent (or ## Versioning -- **Customer app version** (`orisys_customer.__version__`) — shown in Help → About and used for releases. +- **Studio app version** (`orisys_studio.__version__`) — shown in Help → About and used for releases. - **Shared package version** (`orisys_app_shared.__version__`) — internal library version. -- **Shared API** (`orisys_app_shared.API_VERSION`) — compatibility tag. Customer declares `REQUIRES_SHARED_API`; startup logs a warning on mismatch. +- **Shared API** (`orisys_app_shared.API_VERSION`) — compatibility tag. Studio declares `REQUIRES_SHARED_API`; startup logs a warning on mismatch. ## Deferred diff --git a/apps/customer/config/compound_eye.png b/apps/customer/config/compound_eye.png deleted file mode 100644 index 7ea4e12..0000000 Binary files a/apps/customer/config/compound_eye.png and /dev/null differ diff --git a/apps/customer/config/locales/en_US.json b/apps/customer/config/locales/en_US.json deleted file mode 100644 index c2a12c2..0000000 --- a/apps/customer/config/locales/en_US.json +++ /dev/null @@ -1,145 +0,0 @@ -{ - "app": { - "title": "Orisys Customer", - "about_title": "About", - "about_body": "Orisys customer desktop application\nVersion: {app_version}", - "scaffold_hint": "Scaffold shell — acquisition coming later" - }, - "menu": { - "file": "File", - "open_video": "Open video…", - "reveal_config": "Reveal sensor config…", - "view": "View", - "theme": "Theme", - "theme_dark": "Dark", - "theme_light": "Light", - "language": "Language", - "lang_zh": "简体中文", - "lang_en": "English", - "calibration_panel": "Calibration panel", - "finger_3d": "Finger 3D view", - "show_fps": "Show FPS", - "plot_settings": "Plot settings…", - "rotate_arrows": "Rotate arrows 90°", - "run": "Run / Stop", - "preview": "Preview / Stop preview", - "reset": "Reset tracker", - "help": "Help", - "about": "About" - }, - "dialog": { - "plot_settings_title": "Plot settings", - "plot_window": "Time window", - "plot_window_tip": "Force plot time window (seconds)", - "plot_fn_max": "FN max", - "plot_fn_max_tip": "Normal force axis max (Newton mode)", - "plot_shear_max": "Shear max", - "plot_shear_max_tip": "Shear force axis ±max for FX/FY (Newton mode)", - "reset_defaults": "Reset to defaults", - "ok": "OK", - "cancel": "Cancel" - }, - "toolbar": { - "source": "Source", - "source_camera": "Camera", - "source_video": "Video file", - "search_cameras": "Search", - "cam_id": "Cam ID", - "browse_video": "Browse…", - "video_placeholder": "No video selected", - "preview": "Preview", - "preview_stop": "Stop preview", - "run": "Run", - "stop": "Stop", - "run_label": "▶ Run", - "stop_label": "■ Stop", - "run_unavailable": "Not available yet", - "run_disabled_video": "Select a video file first", - "run_disabled_sensor": "Run is not available for this sensor type", - "reset": "Reset", - "units": "Units", - "units_newton": "Newton", - "units_raw": "Raw" - }, - "units": { - "newton_short": "N", - "raw_short": "raw" - }, - "sensor": { - "type": "Sensor type", - "config": "Config", - "thumb_placeholder": "Product model" - }, - "panels": { - "arrow": "2D arrows", - "view_arrows_pure": "Arrows", - "view_arrows_stitched": "Arrows / Captured", - "view_depth": "Depth map", - "view_mode_tip": "Image display mode while running", - "arrow_empty": "No image yet\nPick a video or camera, then press Run", - "arrow_empty_tip": "The deformation arrow image appears here while running or previewing", - "plots": "Force Charts", - "cal": "Calibration", - "mesh3d": "3D mesh", - "mesh3d_empty": "Finger 3D view placeholder —\nOpen3D mesh coming later", - "mesh3d_empty_tip": "3D mesh preview for finger sensors" - }, - "chart": { - "fn": "FN", - "fx": "FX", - "fy": "FY", - "no_data": "—" - }, - "cal": { - "weight": "Weight (N)", - "duration": "Record duration", - "record": "Record", - "recording_stop": "Stop", - "record_disabled": "Press Run to start acquisition before recording samples", - "record_cancelled": "Recording cancelled", - "empty_hint": "No samples for this channel yet: run, pick a channel and press Record.", - "delete_row": "Delete row", - "clear_channel": "Clear channel", - "clear_all": "Clear all", - "export": "Export…", "sn": "SN", - "sn_placeholder": "Serial number (optional)", - "live_raw_label": "Live raw", - "live_raw": "Live raw FN={fn} FX={fx} FY={fy}", - "col_weight": "Weight (N)", - "col_raw": "Raw mean", - "col_n": "n", - "col_std": "σ", - "col_actions": "", - "recording": "Recording calibration samples…", - "record_ok": "Recorded weight={weight} raw={raw}", - "record_failed_few": "Recording failed — too few samples", - "export_ok": "Exported {path}", - "export_local": "Also wrote {path}", - "export_warnings": "Skipped: {detail}", - "export_failed": "Export failed: {error}" - }, - "status": { - "ready": "Ready", - "phase_idle": "Idle", - "phase_connecting": "Connecting", - "phase_running": "Running", - "phase_error": "Error", - "camera_none": "No cameras found", - "camera_found": "Found {count} camera(s): {names}", - "preview_started": "Preview started (camera {camera_id})", - "preview_stopped": "Preview stopped", - "preview_failed": "Preview failed: {error}", - "connecting": "Connecting…", - "run_started": "Running ({source})", - "run_stopped": "Stopped", - "run_failed": "Run failed: {error}", - "video_ended": "Video ended", - "camera_disconnected": "Camera disconnected", - "reset_ok": "Tracker reset", - "finger_run_unavailable": "Run is not available for this sensor type", - "video_required": "Select a video file first", - "config_missing": "Sensor config not found for {type}", - "units_raw_fallback": "Calibration unavailable — showing raw units", - "reveal_failed": "Could not reveal config: {error}" - } -} diff --git a/apps/customer/config/locales/zh_CN.json b/apps/customer/config/locales/zh_CN.json deleted file mode 100644 index ba70bbf..0000000 --- a/apps/customer/config/locales/zh_CN.json +++ /dev/null @@ -1,146 +0,0 @@ -{ - "app": { - "title": "Orisys 客户应用", - "about_title": "关于", - "about_body": "Orisys 客户端桌面应用\n版本: {app_version}", - "scaffold_hint": "脚手架界面 — 采集功能稍后加入" - }, - "menu": { - "file": "文件", - "open_video": "打开视频…", - "reveal_config": "在资源管理器中显示配置…", - "view": "视图", - "theme": "主题", - "theme_dark": "深色", - "theme_light": "浅色", - "language": "语言", - "lang_zh": "简体中文", - "lang_en": "English", - "calibration_panel": "标定面板", - "finger_3d": "手指 3D 视图", - "show_fps": "显示 FPS", - "plot_settings": "绘图设置…", - "rotate_arrows": "旋转箭头图 90°", - "run": "运行 / 停止", - "preview": "预览 / 停止预览", - "reset": "重置追踪器", - "help": "帮助", - "about": "关于" - }, - "dialog": { - "plot_settings_title": "绘图设置", - "plot_window": "时间窗口", - "plot_window_tip": "力曲线时间窗口(秒)", - "plot_fn_max": "FN 上限", - "plot_fn_max_tip": "法向力纵轴上限(牛顿模式)", - "plot_shear_max": "剪切上限", - "plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)", - "reset_defaults": "恢复默认", - "ok": "确定", - "cancel": "取消" - }, - "toolbar": { - "source": "来源", - "source_camera": "摄像机", - "source_video": "视频文件", - "search_cameras": "搜索", - "cam_id": "摄像机", - "browse_video": "浏览…", - "video_placeholder": "未选择视频", - "preview": "预览", - "preview_stop": "停止预览", - "run": "运行", - "stop": "停止", - "run_label": "▶ 运行", - "stop_label": "■ 停止", - "run_unavailable": "当前不可运行", - "run_disabled_video": "请先选择一个视频文件", - "run_disabled_sensor": "此传感器类型暂不支持运行", - "reset": "重置", - "units": "单位", - "units_newton": "牛顿", - "units_raw": "原始" - }, - "units": { - "newton_short": "N", - "raw_short": "raw" - }, - "sensor": { - "type": "传感器类型", - "config": "配置", - "thumb_placeholder": "产品型号" - }, - "panels": { - "arrow": "形变箭头", - "view_arrows_pure": "箭头", - "view_arrows_stitched": "箭头/采集图像", - "view_depth": "深度图", - "view_mode_tip": "运行时的图像显示模式", - "arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」", - "arrow_empty_tip": "运行或预览后在此显示形变箭头图", - "plots": "力曲线图", - "cal": "标定", - "mesh3d": "三维网格", - "mesh3d_empty": "手指三维视图占位 —\nOpen3D 网格稍后加入", - "mesh3d_empty_tip": "手指传感器的三维网格预览" - }, - "chart": { - "fn": "FN", - "fx": "FX", - "fy": "FY", - "no_data": "—" - }, - "cal": { - "weight": "砝码重量 (N)", - "duration": "采集时长", - "record": "记录", - "recording_stop": "停止", - "record_disabled": "请先点击「运行」开始采集,再记录标定样本", - "record_cancelled": "已取消本次采集", - "empty_hint": "当前通道尚无样本:运行后选择通道并点击「记录」。", - "delete_row": "删除行", - "clear_channel": "清空通道", - "clear_all": "全部清空", - "export": "导出…", - "sn": "序列号", - "sn_placeholder": "序列号(可选)", - "live_raw_label": "实时原始值", - "live_raw": "实时原始值 FN={fn} FX={fx} FY={fy}", - "col_weight": "重量 (N)", - "col_raw": "原始均值", - "col_n": "n", - "col_std": "σ", - "col_actions": "", - "recording": "正在采集标定样本…", - "record_ok": "已记录 weight={weight} raw={raw}", - "record_failed_few": "采集失败 — 样本过少", - "export_ok": "已导出 {path}", - "export_local": "同时写入 {path}", - "export_warnings": "跳过: {detail}", - "export_failed": "导出失败: {error}" - }, - "status": { - "ready": "就绪", - "phase_idle": "空闲", - "phase_connecting": "连接中", - "phase_running": "运行中", - "phase_error": "出错", - "camera_none": "未发现摄像机", - "camera_found": "发现 {count} 个摄像机: {names}", - "preview_started": "预览已启动(摄像机 {camera_id})", - "preview_stopped": "预览已停止", - "preview_failed": "预览失败: {error}", - "connecting": "正在连接…", - "run_started": "运行中({source})", - "run_stopped": "已停止", - "run_failed": "启动失败: {error}", - "video_ended": "视频已结束", - "camera_disconnected": "摄像机已断开", - "reset_ok": "追踪器已重置", - "finger_run_unavailable": "此传感器类型暂不支持运行", - "video_required": "请先选择视频文件", - "config_missing": "未找到传感器配置: {type}", - "units_raw_fallback": "无标定数据 — 显示原始单位", - "reveal_failed": "无法打开配置位置: {error}" - } -} diff --git a/apps/customer/config/preferences.default.json b/apps/customer/config/preferences.default.json deleted file mode 100644 index dbb5f9c..0000000 --- a/apps/customer/config/preferences.default.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "language": "zh_CN", - "theme": "dark", - "window_geometry": null, - "window_default_size": [1440, 880], - "camera_id": "0", - "source_mode": "camera", - "video_path": "", - "show_cal_panel": false, - "show_finger_3d": true, - "show_fps": false, - "sensor_type": "box", - "config_name": "", - "units": "newton", - "plot_window_sec": 4.0, - "plot_fn_max": 20.0, - "plot_shear_max": 20.0, - "image_view_mode": "arrows_pure", - "arrow_rotation_deg": 90, - "splitter_sizes": [340, 480, 400] -} diff --git a/apps/customer/config/sensors/box/force_calibration.json b/apps/customer/config/sensors/box/force_calibration.json deleted file mode 100644 index 015fc07..0000000 --- a/apps/customer/config/sensors/box/force_calibration.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "schema_version": 1, - "unit": "N", - "channels": { - "normal": { - "method": "piecewise_linear", - "raw_key": "fnormal", - "extrapolate": true, - "clamp_output_min": 0.0, - "points": [ - [1000.0, 0.0], - [10000.0, 0.377], - [30000.0, 1.377], - [62000.0, 2.377] - ] - }, - "shear": { - "method": "piecewise_linear", - "input": "components", - "signed": true, - "extrapolate": true, - "clamp_output_min": 0.0, - "points": [ - [1000.0, 0.0], - [36000.0, 1.19], - [68000.0, 2.06] - ] - } - } -} diff --git a/apps/customer/config/sensors/box/sensor.json b/apps/customer/config/sensors/box/sensor.json deleted file mode 100644 index af88413..0000000 --- a/apps/customer/config/sensors/box/sensor.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "prototype", - "version": "1.0", - "camera": { - "resolution_raw": [1920, 1080], - "fps": 30, - "units": 15, - "units_used": 9, - "resolution_stitch": [365, 365], - "stitch_res_list": [365,263,123,63] - }, - "stitcher": { - "min_area_frac": 0.01, - "max_area_frac": 0.05, - "max_aspect_ratio": 50, - "index": [1,2,3,6,7,8,11,12,13], - "margin": 10, - "nominal_ov_pos": 100, - "n": [3,3], - "row_tol": 0.6, - "box_size": [250, 250], - "blend":"numba", - "blend_list": ["numpy", "numba","none"] - }, - "tracker": { - "adaptive": true, - "lower_threshold": 12.0, - "upper_threshold": 18.0, - "loop_closure_interval": 1, - "algorithm": "dis", - "dis_preset": "medium", - "dis_mode": 3, - "algorithm_list": ["raft", "dis"], - "hhd_downsample": 3 - }, - "contact": { - "min_contour_area": 3000, - "flow_threshold":5, - "div_threshold":0.05, - "expansion_threshold": 0.01, - "kernel_size": [5,5], - "denoise_sigma": 12 - } -} - - diff --git a/apps/customer/config/sensors/finger/assets/30fps-0703/README.md b/apps/customer/config/sensors/finger/assets/30fps-0703/README.md deleted file mode 100644 index 24a039e..0000000 --- a/apps/customer/config/sensors/finger/assets/30fps-0703/README.md +++ /dev/null @@ -1,9 +0,0 @@ -Place the ORY-FINGER runtime asset bundle files in this directory before -building release wheels: - -- `mesh.npz` -- `camera_intrinsics.npz` -- `world_to_cam.npy` - -These binary assets are validated by the wheel build and are intentionally not -generated by the package build step. diff --git a/apps/customer/config/sensors/finger/assets/30fps-0703/camera_intrinsics.npz b/apps/customer/config/sensors/finger/assets/30fps-0703/camera_intrinsics.npz deleted file mode 100644 index 888b1eb..0000000 Binary files a/apps/customer/config/sensors/finger/assets/30fps-0703/camera_intrinsics.npz and /dev/null differ diff --git a/apps/customer/config/sensors/finger/assets/30fps-0703/mesh.npz b/apps/customer/config/sensors/finger/assets/30fps-0703/mesh.npz deleted file mode 100644 index 4da6bb1..0000000 Binary files a/apps/customer/config/sensors/finger/assets/30fps-0703/mesh.npz and /dev/null differ diff --git a/apps/customer/config/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz b/apps/customer/config/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz deleted file mode 100644 index 15eee73..0000000 Binary files a/apps/customer/config/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz and /dev/null differ diff --git a/apps/customer/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz b/apps/customer/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz deleted file mode 100644 index 995cee0..0000000 Binary files a/apps/customer/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz and /dev/null differ diff --git a/apps/customer/config/sensors/finger/assets/30fps-0703/world_to_cam.npy b/apps/customer/config/sensors/finger/assets/30fps-0703/world_to_cam.npy deleted file mode 100644 index 3d6f897..0000000 Binary files a/apps/customer/config/sensors/finger/assets/30fps-0703/world_to_cam.npy and /dev/null differ diff --git a/apps/customer/config/sensors/finger/assets/visualization/README.md b/apps/customer/config/sensors/finger/assets/visualization/README.md deleted file mode 100644 index fe08404..0000000 --- a/apps/customer/config/sensors/finger/assets/visualization/README.md +++ /dev/null @@ -1,6 +0,0 @@ -Place visualization camera assets in this directory before building release -wheels: - -- `world_to_cam.npy` - -The 3D dashboard uses this pose as the default view preset when available. diff --git a/apps/customer/config/sensors/finger/assets/visualization/camera_intrinsics.npz b/apps/customer/config/sensors/finger/assets/visualization/camera_intrinsics.npz deleted file mode 100644 index 1d211d5..0000000 Binary files a/apps/customer/config/sensors/finger/assets/visualization/camera_intrinsics.npz and /dev/null differ diff --git a/apps/customer/config/sensors/finger/assets/visualization/flatten_remap.npz b/apps/customer/config/sensors/finger/assets/visualization/flatten_remap.npz deleted file mode 100644 index 02ca287..0000000 Binary files a/apps/customer/config/sensors/finger/assets/visualization/flatten_remap.npz and /dev/null differ diff --git a/apps/customer/config/sensors/finger/assets/visualization/mesh.npz b/apps/customer/config/sensors/finger/assets/visualization/mesh.npz deleted file mode 100644 index ad81542..0000000 Binary files a/apps/customer/config/sensors/finger/assets/visualization/mesh.npz and /dev/null differ diff --git a/apps/customer/config/sensors/finger/assets/visualization/pixel_to_xyz.npz b/apps/customer/config/sensors/finger/assets/visualization/pixel_to_xyz.npz deleted file mode 100644 index 4096205..0000000 Binary files a/apps/customer/config/sensors/finger/assets/visualization/pixel_to_xyz.npz and /dev/null differ diff --git a/apps/customer/config/sensors/finger/assets/visualization/world_to_cam.npy b/apps/customer/config/sensors/finger/assets/visualization/world_to_cam.npy deleted file mode 100644 index 2d5e411..0000000 Binary files a/apps/customer/config/sensors/finger/assets/visualization/world_to_cam.npy and /dev/null differ diff --git a/apps/customer/config/sensors/finger/manifest.json b/apps/customer/config/sensors/finger/manifest.json deleted file mode 100644 index 27f92c8..0000000 --- a/apps/customer/config/sensors/finger/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "finger", - "sensor_json": "sensor.json", - "assets_dir": "assets/30fps-0703", - "viz_pose": "assets/visualization/world_to_cam.npy" -} diff --git a/apps/customer/config/sensors/finger/sensor.json b/apps/customer/config/sensors/finger/sensor.json deleted file mode 100644 index ebdb709..0000000 --- a/apps/customer/config/sensors/finger/sensor.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "dd02-ov", - "type": "single_camera", - "version": "1.0", - "camera": { - "resolution_raw": [1280, 720], - "fps": 30, - "resolution_stitch": [280, 300], - "source_scale": 0.4, - "old":[672, 728, 1,0.5], - "work": [504, 546,1], - "work2": [420, 450,0.8], - "work3": [350, 375,4,0.7], - "work4": [280, 300,4,0.4,0.2] - }, - "tracker": { - "adaptive": true, - "lower_threshold": 12.0, - "upper_threshold": 18.0, - "loop_closure_interval": 1, - "algorithm": "dis", - "dis_preset": "medium", - "dis_mode": 1, - "algorithm_list": ["raft", "dis"], - "hhd_downsample": 3 - }, - "contact": { - "min_contour_area": 3000, - "flow_threshold": 0.2, - "div_threshold": 0.022, - "expansion_threshold": 0.01, - "kernel_size": [5, 5], - "denoise_sigma": 12 - } -} diff --git a/apps/customer/config/sensors/mini/sensor.json b/apps/customer/config/sensors/mini/sensor.json deleted file mode 100644 index af88413..0000000 --- a/apps/customer/config/sensors/mini/sensor.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "prototype", - "version": "1.0", - "camera": { - "resolution_raw": [1920, 1080], - "fps": 30, - "units": 15, - "units_used": 9, - "resolution_stitch": [365, 365], - "stitch_res_list": [365,263,123,63] - }, - "stitcher": { - "min_area_frac": 0.01, - "max_area_frac": 0.05, - "max_aspect_ratio": 50, - "index": [1,2,3,6,7,8,11,12,13], - "margin": 10, - "nominal_ov_pos": 100, - "n": [3,3], - "row_tol": 0.6, - "box_size": [250, 250], - "blend":"numba", - "blend_list": ["numpy", "numba","none"] - }, - "tracker": { - "adaptive": true, - "lower_threshold": 12.0, - "upper_threshold": 18.0, - "loop_closure_interval": 1, - "algorithm": "dis", - "dis_preset": "medium", - "dis_mode": 3, - "algorithm_list": ["raft", "dis"], - "hhd_downsample": 3 - }, - "contact": { - "min_contour_area": 3000, - "flow_threshold":5, - "div_threshold":0.05, - "expansion_threshold": 0.01, - "kernel_size": [5,5], - "denoise_sigma": 12 - } -} - - diff --git a/apps/customer/pyproject.toml b/apps/customer/pyproject.toml deleted file mode 100644 index d4f4375..0000000 --- a/apps/customer/pyproject.toml +++ /dev/null @@ -1,26 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "orisys-customer" -version = "0.1.0" -description = "Orisys customer desktop app" -requires-python = ">=3.10" -authors = [{ name = "Orisys-Tech" }] -dependencies = [ - "PyQt5>=5.15", - "orisys-app-shared>=0.1.0", - "orisys>=0.5", - "scipy>=1.10.1", - "pyqtgraph>=0.13", -] - -[project.scripts] -orisys-customer = "orisys_customer.main:main" - -[tool.setuptools] -package-dir = { "" = "src" } - -[tool.setuptools.packages.find] -where = ["src"] diff --git a/apps/customer/run.py b/apps/customer/run.py deleted file mode 100644 index a604305..0000000 --- a/apps/customer/run.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 -"""Launch the Orisys customer app shell.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parent -SRC = ROOT / "src" -SHARED_SRC = ROOT.parents[1] / "shared" / "src" -for path in (SRC, SHARED_SRC): - if path.is_dir() and str(path) not in sys.path: - sys.path.insert(0, str(path)) - -# Ensure path helpers resolve to this app root when launched via run.py -import os - -os.chdir(ROOT) - -from orisys_customer.main import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/apps/customer/src/orisys_customer/app_window.py b/apps/customer/src/orisys_customer/app_window.py deleted file mode 100644 index 0438053..0000000 --- a/apps/customer/src/orisys_customer/app_window.py +++ /dev/null @@ -1,992 +0,0 @@ -"""Main window shell for the customer app.""" - -from __future__ import annotations - -import logging -import subprocess -import sys -from pathlib import Path -from typing import Any - -import numpy as np -from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtGui import QIcon, QImage -from PyQt5.QtWidgets import ( - QAction, - QActionGroup, - QApplication, - QFileDialog, - QFrame, - QHBoxLayout, - QLabel, - QMainWindow, - QMessageBox, - QSplitter, - QStatusBar, - QVBoxLayout, - QWidget, -) - -from orisys_app_shared.force import select_display_forces -from orisys_app_shared.i18n import set_locale, t -from orisys_app_shared.logging import report_error -from orisys_app_shared.paths import logo_path, sensor_json_path -from orisys_app_shared.prefs import save_preferences -from orisys_app_shared.theme import build_qss, clear_theme_cache, color - -from orisys_customer.ui.about_dialog import AboutDialog -from orisys_customer.ui.arrow_panel import ArrowPanel -from orisys_customer.ui.cal_panel import CalPanel -from orisys_customer.ui.force_plots import ForcePlotsPanel -from orisys_customer.ui.mesh3d_panel import Mesh3dPanel -from orisys_customer.ui.plot_settings_dialog import ( - DEFAULT_PLOT_FN_MAX, - DEFAULT_PLOT_SHEAR_MAX, - DEFAULT_PLOT_WINDOW_SEC, - PlotSettingsDialog, -) -from orisys_customer.ui.sensor_picker import SensorPickerWidget -from orisys_customer.ui.toolbar import ToolbarWidget - -logger = logging.getLogger(__name__) - -RUNNABLE_TYPES = frozenset({"box", "mini", "finger"}) -ARROW_ROTATE_TYPES = frozenset({"box", "mini"}) -DEFAULT_SPLITTER_SIZES = (340, 480, 400) -MIN_PANEL_WIDTHS = { - "mesh3d": 240, - "arrow": 300, - "plots": 340, - "cal": 300, -} -SESSION_PHASES = ("idle", "connecting", "running", "error") - - -def _bgr_to_qimage(frame: np.ndarray) -> QImage: - rgb = frame[:, :, ::-1].copy() - h, w, ch = rgb.shape - bytes_per_line = ch * w - return QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888).copy() - - -def _reveal_in_explorer(path: Path) -> None: - path = path.resolve() - if sys.platform == "win32": - subprocess.run(["explorer", "/select,", str(path)], check=False) - return - if sys.platform == "darwin": - subprocess.run(["open", "-R", str(path)], check=False) - return - folder = path.parent if path.is_file() else path - subprocess.run(["xdg-open", str(folder)], check=False) - - -class CustomerMainWindow(QMainWindow): - def __init__(self, prefs: dict[str, Any] | None = None) -> None: - super().__init__() - self.prefs: dict[str, Any] = dict(prefs or {}) - self._theme = str(self.prefs.get("theme") or "dark") - self._language = str(self.prefs.get("language") or "zh_CN") - self._show_cal = bool(self.prefs.get("show_cal_panel", False)) - self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True)) - self._show_fps = bool(self.prefs.get("show_fps", False)) - self._session_phase = "idle" - self._running = False - self._preview = None - self._preview_timer = QTimer(self) - self._preview_timer.setInterval(33) - self._preview_timer.timeout.connect(self._on_preview_tick) - self._run_timer = QTimer(self) - self._run_timer.setInterval(16) - self._run_timer.timeout.connect(self._on_run_tick) - self._box_worker = None - self._finger_worker = None - self._active_worker = None - self._active_sensor_type = "box" - self._last_session_error = "" - self._last_plot_packet_key: tuple[int, float] | None = None - self._last_rendered_view_mode: str | None = None - self._run_source_label = "" - - icon = QIcon(str(logo_path())) - if not icon.isNull(): - self.setWindowIcon(icon) - - central = QWidget() - self.setCentralWidget(central) - root = QVBoxLayout(central) - root.setContentsMargins(10, 10, 10, 10) - root.setSpacing(8) - - top = QHBoxLayout() - top.setSpacing(10) - self.toolbar = ToolbarWidget() - self.sensor_picker = SensorPickerWidget() - sensor_card = QFrame() - sensor_card.setObjectName("contentPanel") - sensor_card_layout = QVBoxLayout(sensor_card) - sensor_card_layout.setContentsMargins(0, 0, 0, 0) - sensor_card_layout.setSpacing(0) - sensor_card_layout.addWidget(self.sensor_picker) - top.addWidget(self.toolbar, 2) - top.addWidget(sensor_card) - root.addLayout(top) - - self.content_splitter = QSplitter(Qt.Horizontal) - self.mesh3d_panel = Mesh3dPanel() - self.arrow_panel = ArrowPanel() - self.force_plots = ForcePlotsPanel(theme=self._theme) - self.cal_panel = CalPanel(theme=self._theme) - self.content_splitter.addWidget(self.mesh3d_panel) - self.content_splitter.addWidget(self.arrow_panel) - self.content_splitter.addWidget(self.force_plots) - self.content_splitter.addWidget(self.cal_panel) - self.content_splitter.setChildrenCollapsible(False) - root.addWidget(self.content_splitter, 1) - - self.setStatusBar(QStatusBar()) - self.status_chip = QLabel() - self.status_chip.setObjectName("statusChip") - self.statusBar().addPermanentWidget(self.status_chip) - self.cal_panel.status_message.connect(self.statusBar().showMessage) - self.cal_panel.calibration_exported.connect(self._on_calibration_exported) - self._set_phase("idle") - self._build_menus() - self._wire_toolbar() - self._wire_sensor_picker() - self._restore_prefs_into_ui() - self._apply_panel_visibility() - self._restore_splitter_sizes() - self._update_run_enabled() - self.cal_panel.set_meta_context( - sensor_type=self.sensor_picker.sensor_type(), - config_ref=self._resolve_config_ref(), - ) - self.retranslate() - self._restore_geometry() - self._apply_default_size() - - @property - def _preview_open(self) -> bool: - return self._preview is not None and bool(self._preview.is_open) - - def _get_preview(self): - if self._preview is None: - from orisys_app_shared.camera import RawPreviewSession - - self._preview = RawPreviewSession() - return self._preview - - def _ensure_workers(self) -> None: - if self._box_worker is not None and self._finger_worker is not None: - return - from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker - from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker - - if self._box_worker is None: - self._box_worker = SdkAcquisitionWorker( - on_error=self._on_worker_error, - on_status=self._on_worker_status, - ) - if self._finger_worker is None: - self._finger_worker = FingerAcquisitionWorker( - on_error=self._on_worker_error, - on_status=self._on_worker_status, - ) - - def _wire_toolbar(self) -> None: - self.toolbar.search_clicked.connect(self._on_search_cameras) - self.toolbar.preview_toggled.connect(self._on_preview_toggled) - self.toolbar.camera_id_changed.connect(self._on_camera_id_changed) - self.toolbar.source_mode_changed.connect(self._on_source_mode_changed) - self.toolbar.browse_video_clicked.connect(self._on_browse_video) - self.toolbar.run_toggled.connect(self._on_run_toggled) - self.toolbar.reset_clicked.connect(self._on_reset) - self.toolbar.units_changed.connect(self._on_units_changed) - - def _wire_sensor_picker(self) -> None: - self.sensor_picker.sensor_type_changed.connect(self._on_sensor_type_changed) - self.sensor_picker.config_changed.connect(self._on_config_changed) - self.arrow_panel.view_mode_changed.connect(self._on_image_view_mode_changed) - - def _restore_prefs_into_ui(self) -> None: - raw_id = str(self.prefs.get("camera_id") or "0").strip() - try: - cam_id = int(raw_id) - except ValueError: - cam_id = 0 - self.toolbar.set_camera_id(cam_id) - self.toolbar.set_source_mode(str(self.prefs.get("source_mode") or "camera")) - self.toolbar.set_video_path(str(self.prefs.get("video_path") or "")) - self.toolbar.set_units(str(self.prefs.get("units") or "newton")) - self.arrow_panel.set_view_mode(str(self.prefs.get("image_view_mode") or "arrows_pure")) - self.arrow_panel.set_fps_visible(self._show_fps) - self.arrow_panel.set_rotation_deg(self.prefs.get("arrow_rotation_deg", 90)) - self._apply_plot_prefs_to_panel() - - sensor_type = str(self.prefs.get("sensor_type") or "box") - config_name = str(self.prefs.get("config_name") or "") - self.sensor_picker.set_sensor_type(sensor_type) - if config_name: - self.sensor_picker.set_config_name(config_name) - else: - self.prefs["config_name"] = self.sensor_picker.config_name() - self._apply_arrow_rotation_for_sensor() - - def _apply_default_size(self) -> None: - """Give the first launch a usable size for the multi-panel layout.""" - default = self.prefs.get("window_default_size") or [1440, 880] - try: - width, height = int(default[0]), int(default[1]) - except (TypeError, ValueError, IndexError): - width, height = 1440, 880 - screen = QApplication.primaryScreen() - if screen is not None: - avail = screen.availableGeometry() - width = min(width, max(1024, avail.width() - 80)) - height = min(height, max(680, avail.height() - 80)) - if not self.prefs.get("window_geometry"): - self.resize(width, height) - - def _restore_splitter_sizes(self) -> None: - stored = self.prefs.get("splitter_sizes") - if isinstance(stored, list) and len(stored) == self.content_splitter.count(): - try: - self.content_splitter.setSizes([int(v) for v in stored]) - return - except (TypeError, ValueError): - pass - sizes = list(DEFAULT_SPLITTER_SIZES) - if not self._show_finger_3d or self.sensor_picker.sensor_type() != "finger": - sizes[0] = 0 - self.content_splitter.setSizes(sizes) - - def _build_menus(self) -> None: - menu_bar = self.menuBar() - - self.file_menu = menu_bar.addMenu("") - self.act_open_video = QAction(self) - self.act_open_video.triggered.connect(self._on_browse_video) - self.file_menu.addAction(self.act_open_video) - self.act_reveal_config = QAction(self) - self.act_reveal_config.triggered.connect(self._on_reveal_config) - self.file_menu.addAction(self.act_reveal_config) - - self.view_menu = menu_bar.addMenu("") - self.act_cal_panel = QAction(self) - self.act_cal_panel.setCheckable(True) - self.act_cal_panel.setChecked(self._show_cal) - self.act_cal_panel.toggled.connect(self._on_cal_panel_toggled) - self.view_menu.addAction(self.act_cal_panel) - - self.act_finger_3d = QAction(self) - self.act_finger_3d.setCheckable(True) - self.act_finger_3d.setChecked(self._show_finger_3d) - self.act_finger_3d.toggled.connect(self._on_finger_3d_toggled) - self.view_menu.addAction(self.act_finger_3d) - - self.act_show_fps = QAction(self) - self.act_show_fps.setCheckable(True) - self.act_show_fps.setChecked(self._show_fps) - self.act_show_fps.toggled.connect(self._on_show_fps_toggled) - self.view_menu.addAction(self.act_show_fps) - - self.act_plot_settings = QAction(self) - self.act_plot_settings.triggered.connect(self._on_plot_settings) - self.view_menu.addAction(self.act_plot_settings) - - self.act_rotate_arrows = QAction(self) - self.act_rotate_arrows.triggered.connect(self._on_rotate_arrows) - self.view_menu.addAction(self.act_rotate_arrows) - self.view_menu.addSeparator() - - self.act_run = QAction(self) - self.act_run.setShortcut("F5") - self.act_run.triggered.connect(self._on_run_shortcut) - self.view_menu.addAction(self.act_run) - - self.act_preview = QAction(self) - self.act_preview.setShortcut("F6") - self.act_preview.triggered.connect(self._on_preview_shortcut) - self.view_menu.addAction(self.act_preview) - - self.act_reset = QAction(self) - self.act_reset.setShortcut("Ctrl+R") - self.act_reset.triggered.connect(self._on_reset) - self.view_menu.addAction(self.act_reset) - self.view_menu.addSeparator() - - self.theme_menu = self.view_menu.addMenu("") - self.theme_group = QActionGroup(self) - self.theme_group.setExclusive(True) - self.act_theme_dark = QAction(self) - self.act_theme_dark.setCheckable(True) - self.act_theme_light = QAction(self) - self.act_theme_light.setCheckable(True) - self.theme_group.addAction(self.act_theme_dark) - self.theme_group.addAction(self.act_theme_light) - self.theme_menu.addAction(self.act_theme_dark) - self.theme_menu.addAction(self.act_theme_light) - self.act_theme_dark.triggered.connect(lambda: self._set_theme("dark")) - self.act_theme_light.triggered.connect(lambda: self._set_theme("light")) - - self.lang_menu = self.view_menu.addMenu("") - self.lang_group = QActionGroup(self) - self.lang_group.setExclusive(True) - self.act_lang_zh = QAction(self) - self.act_lang_zh.setCheckable(True) - self.act_lang_en = QAction(self) - self.act_lang_en.setCheckable(True) - self.lang_group.addAction(self.act_lang_zh) - self.lang_group.addAction(self.act_lang_en) - self.lang_menu.addAction(self.act_lang_zh) - self.lang_menu.addAction(self.act_lang_en) - self.act_lang_zh.triggered.connect(lambda: self._set_language("zh_CN")) - self.act_lang_en.triggered.connect(lambda: self._set_language("en_US")) - - self.help_menu = menu_bar.addMenu("") - self.act_about = QAction(self) - self.act_about.triggered.connect(self._show_about) - self.help_menu.addAction(self.act_about) - - self._sync_menu_checks() - - def _sync_menu_checks(self) -> None: - self.act_theme_dark.setChecked(self._theme != "light") - self.act_theme_light.setChecked(self._theme == "light") - self.act_lang_zh.setChecked(self._language.startswith("zh")) - self.act_lang_en.setChecked(not self._language.startswith("zh")) - self.act_cal_panel.blockSignals(True) - self.act_cal_panel.setChecked(self._show_cal) - self.act_cal_panel.blockSignals(False) - is_finger = self.sensor_picker.sensor_type() == "finger" - self.act_finger_3d.setEnabled(is_finger) - self.act_finger_3d.blockSignals(True) - self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False) - self.act_finger_3d.blockSignals(False) - self._apply_arrow_rotation_for_sensor() - - def _apply_panel_visibility(self) -> None: - is_finger = self.sensor_picker.sensor_type() == "finger" - show_3d = is_finger and bool(self._show_finger_3d) - self.mesh3d_panel.setVisible(show_3d) - self.cal_panel.setVisible(self._show_cal) - self.mesh3d_panel.setMinimumWidth(MIN_PANEL_WIDTHS["mesh3d"]) - self.arrow_panel.setMinimumWidth(MIN_PANEL_WIDTHS["arrow"]) - self.force_plots.setMinimumWidth(MIN_PANEL_WIDTHS["plots"]) - self.cal_panel.setMinimumWidth(MIN_PANEL_WIDTHS["cal"]) - self.act_finger_3d.setEnabled(is_finger) - self.act_finger_3d.blockSignals(True) - self.act_finger_3d.setChecked(show_3d) - self.act_finger_3d.blockSignals(False) - for i in range(self.content_splitter.count()): - widget = self.content_splitter.widget(i) - if widget is self.mesh3d_panel: - self.content_splitter.setStretchFactor(i, 2 if show_3d else 0) - elif widget is self.arrow_panel: - self.content_splitter.setStretchFactor(i, 3) - elif widget is self.force_plots: - self.content_splitter.setStretchFactor(i, 4) - elif widget is self.cal_panel: - self.content_splitter.setStretchFactor(i, 3 if self._show_cal else 0) - - def _update_run_enabled(self) -> None: - sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES - source_ok = True - reason = "" - if not sensor_ok: - reason = t("toolbar.run_disabled_sensor") - elif self.toolbar.source_mode() == "video" and not self.toolbar.video_path(): - source_ok = False - reason = t("toolbar.run_disabled_video") - self.toolbar.set_run_tooltip_reason(reason) - self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok)) - if not self._running: - self.toolbar.refresh_labels() - self._sync_shortcut_actions() - - def retranslate(self) -> None: - self.setWindowTitle(t("app.title")) - self.file_menu.setTitle(t("menu.file")) - self.act_open_video.setText(t("menu.open_video")) - self.act_reveal_config.setText(t("menu.reveal_config")) - self.view_menu.setTitle(t("menu.view")) - self.act_cal_panel.setText(t("menu.calibration_panel")) - self.act_finger_3d.setText(t("menu.finger_3d")) - self.act_show_fps.setText(t("menu.show_fps")) - self.act_plot_settings.setText(t("menu.plot_settings")) - self.act_rotate_arrows.setText(t("menu.rotate_arrows")) - self.act_run.setText(t("menu.run")) - self.act_preview.setText(t("menu.preview")) - self.act_reset.setText(t("menu.reset")) - self.theme_menu.setTitle(t("menu.theme")) - self.act_theme_dark.setText(t("menu.theme_dark")) - self.act_theme_light.setText(t("menu.theme_light")) - self.lang_menu.setTitle(t("menu.language")) - self.act_lang_zh.setText(t("menu.lang_zh")) - self.act_lang_en.setText(t("menu.lang_en")) - self.help_menu.setTitle(t("menu.help")) - self.act_about.setText(t("menu.about")) - self.toolbar.retranslate() - self.sensor_picker.retranslate() - self.mesh3d_panel.retranslate() - self.arrow_panel.retranslate() - self.force_plots.retranslate() - self.cal_panel.retranslate() - self._refresh_status_chip() - if not self._preview_open and not self._running: - self.statusBar().showMessage(t("status.ready")) - self._update_run_enabled() - - def _set_phase(self, phase: str) -> None: - """Track the acquisition phase and reflect it in the status chip.""" - self._session_phase = phase if phase in SESSION_PHASES else "idle" - self._refresh_status_chip() - - def _refresh_status_chip(self) -> None: - chip = getattr(self, "status_chip", None) - if chip is None: - return - phase = self._session_phase if self._session_phase in SESSION_PHASES else "idle" - token = f"status_{phase}" - chip.setText(t(f"status.phase_{phase}")) - chip.setStyleSheet( - f"QLabel#statusChip {{ color: {color(self._theme, token)};" - " background: transparent; font-weight: 600; padding: 0 10px 0 4px; }" - ) - - def _set_theme(self, theme: str) -> None: - self._theme = theme - self.prefs["theme"] = theme - clear_theme_cache() - app = QApplication.instance() - if app is not None: - app.setStyleSheet(build_qss(theme)) - self.force_plots.set_theme(theme) - self.cal_panel.set_theme(theme) - self._refresh_status_chip() - self._sync_menu_checks() - self._persist_prefs() - - def _set_language(self, language: str) -> None: - self._language = language - self.prefs["language"] = language - set_locale(language) - self.retranslate() - self._sync_menu_checks() - self._persist_prefs() - - def _show_about(self) -> None: - AboutDialog(self).exec_() - - def _on_camera_id_changed(self, value: int) -> None: - self.prefs["camera_id"] = str(int(value)) - self._persist_prefs() - - def _on_source_mode_changed(self, mode: str) -> None: - self.prefs["source_mode"] = mode - self._persist_prefs() - self._update_run_enabled() - - def _on_units_changed(self, units: str) -> None: - self.prefs["units"] = units - self.force_plots.set_fixed_newton(str(units) == "newton") - self._persist_prefs() - - def _on_image_view_mode_changed(self, mode: str) -> None: - self.prefs["image_view_mode"] = str(mode) - self._last_rendered_view_mode = None - self._persist_prefs() - if self._running: - if self._active_worker is not None: - self._render_run_image(self._active_worker.latest()) - - def _on_plot_settings(self) -> None: - dialog = PlotSettingsDialog( - self, - window_sec=float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC), - fn_max=float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX), - shear_max=float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX), - units=self.toolbar.units(), - ) - if dialog.exec_() != PlotSettingsDialog.Accepted: - return - window_sec, fn_max, shear_max = dialog.values() - self.prefs["plot_window_sec"] = window_sec - self.prefs["plot_fn_max"] = fn_max - self.prefs["plot_shear_max"] = shear_max - self.force_plots.set_window_sec(window_sec) - self.force_plots.set_axis_limits(fn_max, shear_max) - self._last_plot_packet_key = None - self._persist_prefs() - - def _apply_plot_prefs_to_panel(self) -> None: - self.force_plots.set_window_sec( - float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC) - ) - self.force_plots.set_axis_limits( - float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX), - float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX), - ) - self.force_plots.set_fixed_newton(self.toolbar.units() == "newton") - - def _on_cal_panel_toggled(self, visible: bool) -> None: - self._show_cal = bool(visible) - self.prefs["show_cal_panel"] = self._show_cal - self._apply_panel_visibility() - self._persist_prefs() - - def _on_calibration_exported(self, path: str) -> None: - """Apply newly written force_calibration.json to the running Sensor.""" - self._ensure_workers() - for worker in (self._box_worker, self._finger_worker): - if worker is not None and hasattr(worker, "set_pending_calibration"): - worker.set_pending_calibration(path) - - def _on_finger_3d_toggled(self, visible: bool) -> None: - if self.sensor_picker.sensor_type() != "finger": - return - self._show_finger_3d = bool(visible) - self.prefs["show_finger_3d"] = self._show_finger_3d - self._apply_panel_visibility() - self._persist_prefs() - - def _on_show_fps_toggled(self, visible: bool) -> None: - self._show_fps = bool(visible) - self.prefs["show_fps"] = self._show_fps - self.arrow_panel.set_fps_visible(self._show_fps) - if not self._show_fps: - self.arrow_panel.set_fps(None) - self._persist_prefs() - - def _on_sensor_type_changed(self, sensor_type: str) -> None: - self.prefs["sensor_type"] = str(sensor_type) - self.prefs["config_name"] = self.sensor_picker.config_name() - self.cal_panel.set_meta_context( - sensor_type=str(sensor_type), - config_ref=self._resolve_config_ref(), - ) - self._apply_panel_visibility() - self._apply_arrow_rotation_for_sensor() - self._update_run_enabled() - self._persist_prefs() - - def _apply_arrow_rotation_for_sensor(self) -> None: - enabled = self.sensor_picker.sensor_type() in ARROW_ROTATE_TYPES - self.arrow_panel.set_rotation_enabled(enabled) - self.act_rotate_arrows.setEnabled(enabled) - - def _on_rotate_arrows(self) -> None: - if self.sensor_picker.sensor_type() not in ARROW_ROTATE_TYPES: - return - deg = self.arrow_panel.rotate_90_ccw() - self.prefs["arrow_rotation_deg"] = deg - self._persist_prefs() - - # ----------------------------------------------------------- shortcuts - def _sync_shortcut_actions(self) -> None: - if getattr(self, "act_run", None) is None: - return - self.act_run.setEnabled(self.toolbar.btn_run.isEnabled()) - self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled()) - self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled()) - - def _on_run_shortcut(self) -> None: - if not self.toolbar.btn_run.isEnabled(): - return - self.toolbar.btn_run.setChecked(not self.toolbar.btn_run.isChecked()) - - def _on_preview_shortcut(self) -> None: - if not self.toolbar.btn_preview.isEnabled(): - return - self.toolbar.btn_preview.setChecked(not self.toolbar.btn_preview.isChecked()) - - def _on_config_changed(self, config_name: str) -> None: - self.prefs["config_name"] = str(config_name) - self._persist_prefs() - - def _on_browse_video(self) -> None: - if self._running or self._preview_open: - return - start = self.toolbar.video_path() or str(Path.home()) - path, _ = QFileDialog.getOpenFileName( - self, - t("menu.open_video"), - start, - "Video (*.mp4 *.avi *.mov *.mkv);;All (*)", - ) - if not path: - return - self.toolbar.set_source_mode("video") - self.toolbar.set_video_path(path) - self.prefs["source_mode"] = "video" - self.prefs["video_path"] = path - self._persist_prefs() - self._update_run_enabled() - - def _on_reveal_config(self) -> None: - sensor_type = self.sensor_picker.sensor_type() - path = sensor_json_path(sensor_type) - if path is None: - cfg = self.sensor_picker.config_name() - candidate = Path(cfg) - path = candidate if candidate.is_file() else None - if path is None: - self.statusBar().showMessage(t("status.config_missing", type=sensor_type)) - return - try: - _reveal_in_explorer(path) - except Exception as exc: - self.statusBar().showMessage(t("status.reveal_failed", error=str(exc))) - - def _resolve_config_ref(self) -> str | None: - sensor_type = self.sensor_picker.sensor_type() - local = sensor_json_path(sensor_type) - if local is not None: - return str(local) - cfg = self.sensor_picker.config_name() - if cfg and Path(cfg).is_file(): - return cfg - if sensor_type in RUNNABLE_TYPES: - return sensor_type - return None - - def _on_search_cameras(self) -> None: - if self._preview_open or self._running: - return - from orisys_app_shared.camera import list_cameras, pick_default_camera - - preferred = str(self.toolbar.camera_id()) - self.statusBar().showMessage(t("toolbar.search_cameras") + "…") - QApplication.processEvents() - cameras = list_cameras() - if not cameras: - self.statusBar().showMessage(t("status.camera_none")) - return - names = ", ".join(cam.label() for cam in cameras) - self.statusBar().showMessage( - t("status.camera_found", count=len(cameras), names=names) - ) - selected = pick_default_camera(cameras, preferred) - if selected is not None: - self.toolbar.set_camera_id(int(selected)) - self.prefs["camera_id"] = str(selected) - self._persist_prefs() - - def _on_preview_toggled(self, checked: bool) -> None: - if checked: - self._start_preview() - else: - self._stop_preview(announce=True) - - def _start_preview(self) -> None: - if self._running or self.toolbar.source_mode() != "camera": - self.toolbar.set_preview_checked(False) - return - camera_id = self.toolbar.camera_id() - try: - self._get_preview().open(camera_id) - except Exception as exc: - msg = t("status.preview_failed", error=str(exc)) - self.statusBar().showMessage(msg) - self.toolbar.set_preview_checked(False) - self.arrow_panel.clear_preview(msg) - return - self.toolbar.set_source_controls_enabled(False) - self.toolbar.btn_preview.setEnabled(True) - self._update_run_enabled() - self._preview_timer.start() - self.statusBar().showMessage(t("status.preview_started", camera_id=camera_id)) - - def _stop_preview(self, *, announce: bool) -> None: - self._preview_timer.stop() - if self._preview is not None: - self._preview.close() - if not self._running: - self.toolbar.set_source_controls_enabled(True) - self.arrow_panel.clear_preview(t("panels.arrow_empty")) - self.toolbar.set_preview_checked(False) - self._update_run_enabled() - if announce: - self.statusBar().showMessage(t("status.preview_stopped")) - - def _on_preview_tick(self) -> None: - if self._preview is None: - return - frame = self._preview.read() - if frame is None: - self._stop_preview(announce=True) - self.statusBar().showMessage(t("status.preview_failed", error="no frame")) - return - self.arrow_panel.show_frame(_bgr_to_qimage(frame)) - - def _on_run_toggled(self, checked: bool) -> None: - if checked: - self._start_run() - else: - self._stop_run(reason="user_stop") - - def _start_run(self) -> None: - from orisys_app_shared.acquisition.sdk_worker import orisys_importable - - sensor_type = self.sensor_picker.sensor_type() - if sensor_type not in RUNNABLE_TYPES: - self.toolbar.set_run_checked(False) - self.statusBar().showMessage(t("status.finger_run_unavailable")) - return - ok, detail = orisys_importable() - if not ok: - self.toolbar.set_run_checked(False) - QMessageBox.warning(self, t("app.title"), detail) - return - config_ref = self._resolve_config_ref() - if not config_ref: - self.toolbar.set_run_checked(False) - self.statusBar().showMessage( - t("status.config_missing", type=sensor_type) - ) - return - source = self.toolbar.active_source() - if source is None: - self.toolbar.set_run_checked(False) - self.statusBar().showMessage(t("status.video_required")) - return - - if self._preview_open: - self._stop_preview(announce=False) - - self.force_plots.reset() - self._last_plot_packet_key = None - self._last_rendered_view_mode = None - self._last_session_error = "" - self._run_source_label = str(source) - self._active_sensor_type = sensor_type - self._ensure_workers() - self._active_worker = ( - self._finger_worker if sensor_type == "finger" else self._box_worker - ) - try: - self._active_worker.start(source, config_ref=config_ref) - except Exception as exc: - report_error(exc, message="Run failed") - self.toolbar.set_run_checked(False) - self.statusBar().showMessage(t("status.run_failed", error=str(exc))) - return - - self._running = True - self._set_phase("connecting") - self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref) - self.cal_panel.set_running(True) - self.toolbar.set_source_controls_enabled(False) - self.sensor_picker.set_picker_enabled(False) - self.toolbar.set_reset_enabled(False) - self._run_timer.start() - self.statusBar().showMessage(t("status.connecting")) - logger.info( - "Connecting type=%s source=%s config=%s", - sensor_type, - source, - config_ref, - ) - - def _stop_run(self, *, reason: str = "user_stop") -> None: - self._run_timer.stop() - if self._active_worker is not None: - self._active_worker.stop() - self._running = False - self._set_phase("error" if reason == "failed" else "idle") - self.cal_panel.set_running(False) - self.toolbar.set_run_checked(False) - self.toolbar.set_reset_enabled(False) - self.toolbar.set_source_controls_enabled(True) - self.sensor_picker.set_picker_enabled(True) - self._update_run_enabled() - keep_frame = reason in ("ended", "disconnected") - if not keep_frame: - self.arrow_panel.clear_preview(t("panels.arrow_empty")) - else: - self.arrow_panel.set_fps(None) - if reason == "ended": - self.statusBar().showMessage(t("status.video_ended")) - elif reason == "disconnected": - self.statusBar().showMessage(t("status.camera_disconnected")) - elif reason == "failed": - pass # caller sets status - elif reason == "user_stop": - self.statusBar().showMessage(t("status.run_stopped")) - else: - self.statusBar().showMessage(t("status.run_stopped")) - - def _on_reset(self) -> None: - if self._session_phase != "running" or self._active_worker is None: - return - try: - self._active_worker.reset() - self.force_plots.reset() - self._last_plot_packet_key = None - self.statusBar().showMessage(t("status.reset_ok")) - except Exception as exc: - self.statusBar().showMessage(t("status.run_failed", error=str(exc))) - - def _on_worker_error(self, message: str) -> None: - self._last_session_error = message - logger.error("Acquisition error: %s", message) - - def _on_worker_status(self, message: str) -> None: - logger.info("%s", message) - - def _on_run_tick(self) -> None: - if self._active_worker is None: - return - if self._last_session_error: - err = self._last_session_error - self._last_session_error = "" - self._active_worker.consume_end_reason() - self._stop_run(reason="failed") - self.statusBar().showMessage(t("status.run_failed", error=err)) - return - - end_reason = self._active_worker.consume_end_reason() - if end_reason in ("ended", "disconnected"): - self._stop_run(reason=end_reason) - return - if end_reason == "error": - self._stop_run(reason="failed") - self.statusBar().showMessage(t("status.run_failed", error="acquisition error")) - return - - if not self._active_worker.is_running and self._running: - leftover = self._active_worker.consume_end_reason() - if leftover in ("ended", "disconnected"): - self._stop_run(reason=leftover) - elif leftover == "error": - self._stop_run(reason="failed") - self.statusBar().showMessage( - t("status.run_failed", error="acquisition error") - ) - else: - self._stop_run(reason="user_stop") - return - - packet = self._active_worker.latest() - if packet is None: - return - packet_key = (int(packet.session_id), float(packet.timestamp)) - is_new = packet_key != self._last_plot_packet_key - - if self._session_phase == "connecting" and is_new: - self._set_phase("running") - self.toolbar.set_reset_enabled(True) - self._sync_shortcut_actions() - self.statusBar().showMessage( - t("status.run_started", source=self._run_source_label) - ) - - if is_new: - self._last_plot_packet_key = packet_key - display = select_display_forces( - units_pref=self.toolbar.units(), - force_calibrated=packet.force_calibrated, - fn=packet.normal_force, - fx=packet.shear_x, - fy=packet.shear_y, - fn_raw=packet.normal_force_raw, - fx_raw=packet.shear_x_raw, - fy_raw=packet.shear_y_raw, - ) - if display.using_raw_fallback: - self.statusBar().showMessage(t("status.units_raw_fallback")) - self.force_plots.push( - packet.timestamp, - display.fn, - display.fx, - display.fy, - unit=display.unit, - ) - if self._show_cal: - self.cal_panel.push_raw( - packet.normal_force_raw, - packet.shear_x_raw, - packet.shear_y_raw, - packet.timestamp, - ) - - mode = self.arrow_panel.view_mode() - if is_new or mode != self._last_rendered_view_mode: - self._render_run_image(packet) - if self._show_fps and is_new: - self.arrow_panel.set_fps(packet.fps) - - def _render_run_image(self, packet) -> None: - if packet is None: - return - from orisys_app_shared.acquisition.arrows import ( - depth_map_to_bgr, - draw_arrow_display, - hex_to_bgr, - ) - - mode = self.arrow_panel.view_mode() - self._last_rendered_view_mode = mode - draw_style = "finger" if self._active_sensor_type == "finger" else "box" - if mode == "depth": - depth_bgr = depth_map_to_bgr(packet.depth) - frame = depth_bgr if depth_bgr is not None else packet.processed_frame - elif mode == "arrows_stitched": - frame = draw_arrow_display( - packet.processed_frame, - packet.flow, - background="captured", - draw_style=draw_style, - valid_mask=packet.valid_mask, - ) - else: - canvas_bgr = hex_to_bgr(color(self._theme, "arrow_canvas", "#121316")) - frame = draw_arrow_display( - packet.processed_frame, - packet.flow, - background="pure_color", - canvas_bgr=canvas_bgr, - draw_style=draw_style, - valid_mask=packet.valid_mask, - ) - self.arrow_panel.show_frame(_bgr_to_qimage(frame)) - - def _restore_geometry(self) -> None: - geo = self.prefs.get("window_geometry") - if isinstance(geo, str) and geo: - try: - from PyQt5.QtCore import QByteArray - - self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii"))) - except Exception: - logger.debug("Could not restore window geometry", exc_info=True) - - def _persist_prefs(self) -> None: - geo = self.saveGeometry().toHex().data().decode("ascii") - self.prefs["window_geometry"] = geo - self.prefs["splitter_sizes"] = [int(v) for v in self.content_splitter.sizes()] - self.prefs["theme"] = self._theme - self.prefs["language"] = self._language - self.prefs["camera_id"] = str(self.toolbar.camera_id()) - self.prefs["source_mode"] = self.toolbar.source_mode() - self.prefs["video_path"] = self.toolbar.video_path() - self.prefs["units"] = self.toolbar.units() - self.prefs["image_view_mode"] = self.arrow_panel.view_mode() - self.prefs["arrow_rotation_deg"] = self.arrow_panel.rotation_deg() - self.prefs.setdefault("plot_window_sec", DEFAULT_PLOT_WINDOW_SEC) - self.prefs.setdefault("plot_fn_max", DEFAULT_PLOT_FN_MAX) - self.prefs.setdefault("plot_shear_max", DEFAULT_PLOT_SHEAR_MAX) - self.prefs["show_cal_panel"] = bool(self._show_cal) - self.prefs["show_finger_3d"] = bool(self._show_finger_3d) - self.prefs["show_fps"] = bool(self._show_fps) - self.prefs["sensor_type"] = self.sensor_picker.sensor_type() - self.prefs["config_name"] = self.sensor_picker.config_name() - save_preferences(self.prefs) - - def closeEvent(self, event) -> None: # noqa: N802 - self._stop_preview(announce=False) - self._stop_run(reason="user_stop") - self._persist_prefs() - logger.info("Customer shell closed; preferences saved") - super().closeEvent(event) diff --git a/apps/customer/src/orisys_customer/main.py b/apps/customer/src/orisys_customer/main.py deleted file mode 100644 index 49ebafd..0000000 --- a/apps/customer/src/orisys_customer/main.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Customer app entrypoint.""" - -from __future__ import annotations - -import logging -import sys - -from PyQt5.QtWidgets import QApplication - -from orisys_app_shared import API_VERSION, __version__ as shared_version -from orisys_app_shared.compat import check_shared_compat -from orisys_app_shared.i18n import set_locale -from orisys_app_shared.logging import init_logging, install_exception_hooks -from orisys_app_shared.paths import prefer_local_sensor_bundles -from orisys_app_shared.prefs import load_preferences -from orisys_app_shared.theme import build_qss - -from orisys_customer import REQUIRES_SHARED_API, __version__ as app_version -from orisys_customer.app_window import CustomerMainWindow - -logger = logging.getLogger(__name__) - - -def main() -> int: - init_logging("orisys-customer") - install_exception_hooks() - preferred = prefer_local_sensor_bundles() - if preferred is not None: - logger.info("Preferring local sensor bundles at %s", preferred) - check_shared_compat(REQUIRES_SHARED_API) - logger.info( - "Versions: app=%s shared=%s api=%s (requires %s)", - app_version, - shared_version, - API_VERSION, - REQUIRES_SHARED_API, - ) - - prefs = load_preferences() - language = str(prefs.get("language") or "zh_CN") - theme = str(prefs.get("theme") or "dark") - set_locale(language) - - app = QApplication(sys.argv) - app.setApplicationName("Orisys Customer") - app.setApplicationVersion(app_version) - app.setStyleSheet(build_qss(theme)) - - window = CustomerMainWindow(prefs=prefs) - window.show() - logger.info("Customer shell started (theme=%s, language=%s)", theme, language) - return int(app.exec_()) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/apps/customer/src/orisys_customer/ui/about_dialog.py b/apps/customer/src/orisys_customer/ui/about_dialog.py deleted file mode 100644 index 3308a09..0000000 --- a/apps/customer/src/orisys_customer/ui/about_dialog.py +++ /dev/null @@ -1,44 +0,0 @@ -"""About dialog for Help > About.""" - -from __future__ import annotations - -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QPixmap -from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout - -from orisys_app_shared.i18n import t -from orisys_app_shared.paths import logo_topbar_path - -from orisys_customer import __version__ as app_version - - -class AboutDialog(QDialog): - def __init__(self, parent=None) -> None: - super().__init__(parent) - self.setWindowTitle(t("menu.about")) - self.setModal(True) - self.resize(420, 220) - - root = QVBoxLayout(self) - row = QHBoxLayout() - logo = QLabel() - pix = QPixmap(str(logo_topbar_path())) - if not pix.isNull(): - logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation)) - row.addWidget(logo) - row.addStretch(1) - root.addLayout(row) - - self.body = QLabel() - self.body.setWordWrap(True) - self.body.setTextInteractionFlags(Qt.TextSelectableByMouse) - root.addWidget(self.body) - - close_btn = QPushButton("OK") - close_btn.clicked.connect(self.accept) - root.addWidget(close_btn, alignment=Qt.AlignRight) - self.retranslate() - - def retranslate(self) -> None: - self.setWindowTitle(t("app.about_title")) - self.body.setText(t("app.about_body", app_version=app_version)) diff --git a/apps/customer/tests/conftest.py b/apps/customer/tests/conftest.py deleted file mode 100644 index 69f2bdd..0000000 --- a/apps/customer/tests/conftest.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Shared fixtures for the customer app UI tests. - -The tests render real Qt widgets with the offscreen platform plugin, so they need -no display and no camera hardware. A single ``QApplication`` is shared by the -whole session (Qt forbids creating more than one). -""" - -from __future__ import annotations - -import os -import sys -from pathlib import Path - -import pytest - -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: - _fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" - if _fonts.is_dir(): - os.environ["QT_QPA_FONTDIR"] = str(_fonts) - - -@pytest.fixture(scope="session") -def qt_app(): - from PyQt5.QtWidgets import QApplication - - app = QApplication.instance() or QApplication([]) - yield app - - -@pytest.fixture -def make_window(qt_app, tmp_path, monkeypatch): - """Build a ``CustomerMainWindow`` whose preference writes stay in tmp_path.""" - from orisys_app_shared import prefs as prefs_pkg - from orisys_app_shared import paths as paths_mod - from orisys_customer.app_window import CustomerMainWindow - - prefs_file = tmp_path / "preference.json" - monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file) - monkeypatch.setattr(prefs_pkg.store, "preference_path", lambda: prefs_file) - - default_prefs = { - "language": "zh_CN", - "theme": "dark", - "sensor_type": "box", - "source_mode": "camera", - } - - created = [] - - def _make(**overrides): - data = dict(default_prefs) - data.update(overrides) - window = CustomerMainWindow(prefs=data) - created.append(window) - return window - - yield _make - - for window in created: - try: - window.close() - except Exception: - pass - - -def visible_descendants(widget): - """Yield every visible descendant widget of ``widget``.""" - for child in widget.findChildren(object): - if not hasattr(child, "isVisible") or not hasattr(child, "geometry"): - continue - if child.isVisible(): - yield child diff --git a/apps/customer/tests/test_locales.py b/apps/customer/tests/test_locales.py deleted file mode 100644 index d4b6c5d..0000000 --- a/apps/customer/tests/test_locales.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Static checks: translation keys and preference defaults stay in sync.""" - -from __future__ import annotations - -import json -import re -from pathlib import Path - -APP_ROOT = Path(__file__).resolve().parents[1] -SRC_DIR = APP_ROOT / "src" -LOCALES_DIR = APP_ROOT / "config" / "locales" -DEFAULTS_JSON = APP_ROOT / "config" / "preferences.default.json" - -REPO_ROOT = APP_ROOT.parents[1] -STORE_PY = REPO_ROOT / "shared" / "src" / "orisys_app_shared" / "prefs" / "store.py" - -_T_CALL = re.compile(r"""\bt\(\s*["']([A-Za-z0-9_.]+)["']""") -_DEFAULT_BLOCK = re.compile(r"_DEFAULTS[^=]*=\s*\{(?P
.*?)\n\}", re.DOTALL) -_KEY = re.compile(r"""["']([A-Za-z0-9_]+)["']\s*:""") - - -def _load_locale(name: str) -> dict: - return json.loads((LOCALES_DIR / f"{name}.json").read_text(encoding="utf-8")) - - -def _flatten(data: dict, prefix: str = "") -> set[str]: - keys: set[str] = set() - for key, value in data.items(): - dotted = f"{prefix}{key}" - if isinstance(value, dict): - keys |= _flatten(value, f"{dotted}.") - else: - keys.add(dotted) - return keys - - -def _referenced_keys() -> set[str]: - found: set[str] = set() - for path in sorted(SRC_DIR.rglob("*.py")): - found |= set(_T_CALL.findall(path.read_text(encoding="utf-8"))) - return found - - -def test_locales_parity_and_used_keys(): - zh = _flatten(_load_locale("zh_CN")) - en = _flatten(_load_locale("en_US")) - - assert zh == en, { - "zh_only": sorted(zh - en), - "en_only": sorted(en - zh), - } - - referenced = _referenced_keys() - assert referenced, "no t() calls found — the source scan is broken" - missing = sorted(key for key in referenced if key not in zh) - assert not missing, f"t() keys missing from locale files: {missing}" - - -def test_preference_defaults_match_store_defaults(): - json_keys = set(json.loads(DEFAULTS_JSON.read_text(encoding="utf-8"))) - source = STORE_PY.read_text(encoding="utf-8") - match = _DEFAULT_BLOCK.search(source) - assert match, "could not locate _DEFAULTS in prefs/store.py" - store_keys = set(_KEY.findall(match.group("body"))) - - assert json_keys == store_keys, { - "json_only": sorted(json_keys - store_keys), - "store_only": sorted(store_keys - json_keys), - } - - -def test_locale_files_are_valid_utf8_json(): - for name in ("zh_CN", "en_US"): - data = _load_locale(name) - assert isinstance(data, dict) - assert data.get("app", {}).get("title") diff --git a/apps/customer/tests/test_ui_smoke.py b/apps/customer/tests/test_ui_smoke.py deleted file mode 100644 index e1369df..0000000 --- a/apps/customer/tests/test_ui_smoke.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens.""" - -from __future__ import annotations - -import pytest - -from tests.conftest import visible_descendants - -RESOLUTIONS = ((1280, 720), (1600, 900), (1920, 1080)) - - -def _show(window, size): - window.resize(*size) - window.show() - from PyQt5.QtWidgets import QApplication - - QApplication.processEvents() - return window - - -@pytest.mark.parametrize(("theme", "sensor_type"), [ - ("dark", "box"), - ("light", "box"), - ("dark", "finger"), - ("light", "finger"), -]) -def test_window_builds_in_every_theme(make_window, theme, sensor_type): - window = _show(make_window(theme=theme, sensor_type=sensor_type), (1400, 880)) - assert window.windowTitle() - assert window.content_splitter.count() == 4 - - -@pytest.mark.parametrize("size", RESOLUTIONS) -def test_panels_fit_within_window(make_window, size): - window = _show( - make_window(sensor_type="finger", show_cal_panel=True), - size, - ) - sizes = window.content_splitter.sizes() - panels = [ - window.mesh3d_panel, - window.arrow_panel, - window.force_plots, - window.cal_panel, - ] - for panel, width in zip(panels, sizes): - assert panel.isVisible() - assert width >= panel.minimumWidth(), ( - f"{panel.objectName() or type(panel).__name__} too narrow: {width}" - ) - assert window.force_plots.width() >= window.force_plots.minimumWidth() - assert window.arrow_panel.width() >= window.arrow_panel.minimumWidth() - - -@pytest.mark.parametrize("size", RESOLUTIONS) -def test_children_stay_inside_their_parent(make_window, size): - window = _show(make_window(sensor_type="finger", show_cal_panel=True), size) - oversize = [] - for child in visible_descendants(window): - parent = child.parentWidget() - if parent is None or not parent.isVisible(): - continue - geometry = child.geometry() - if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1: - oversize.append( - f"{type(child).__name__} {geometry.width()}x{geometry.height()}" - f" > parent {parent.width()}x{parent.height()}" - ) - assert not oversize, oversize - - -def test_theme_tokens_have_identical_keys(): - from orisys_app_shared.theme import build_qss, load_colors - - dark = set(load_colors("dark")) - light = set(load_colors("light")) - assert dark == light, {"dark_only": sorted(dark - light), "light_only": sorted(light - dark)} - assert len(build_qss("dark")) > 1000 - assert len(build_qss("light")) > 1000 - - -def test_arrow_canvas_differs_from_accent(): - """The pure-color arrow canvas must not collide with the accent pen color.""" - from orisys_app_shared.theme import load_colors - - for theme in ("dark", "light"): - colors = load_colors(theme) - assert colors["arrow_canvas"] != colors["accent"], theme - - -def test_disabled_buttons_use_muted_text_not_hover_background(): - from orisys_app_shared.theme import build_qss, load_colors - - for theme in ("dark", "light"): - colors = load_colors(theme) - qss = build_qss(theme) - assert f"color: {colors['text_muted']}" in qss, theme - # The old rule painted disabled buttons with the brighter hover color. - assert f"background-color: {colors['button_hover']};\n border-color: {colors['panel_border']}" not in qss - - -def test_splitter_sizes_round_trip_through_prefs(make_window): - window = _show(make_window(), (1440, 880)) - window.content_splitter.setSizes([300, 420, 520, 0]) - window._persist_prefs() - stored = window.prefs["splitter_sizes"] - assert len(stored) == 4 - assert stored[2] > stored[0] - - -def test_status_chip_tracks_phase(make_window): - window = _show(make_window(), (1400, 880)) - assert window._session_phase == "idle" - window._set_phase("running") - assert window.status_chip.text() - assert "status_running" not in window.status_chip.text() - window._set_phase("nonexistent-phase") - assert window._session_phase == "idle" - - -def test_run_tooltip_explains_disabled_reason(make_window): - window = _show(make_window(source_mode="video", video_path=""), (1400, 880)) - assert not window.toolbar.btn_run.isEnabled() - assert window.toolbar.btn_run.toolTip() - - window.toolbar.set_video_path("C:/tmp/demo.mp4") - window._update_run_enabled() - assert window.toolbar.btn_run.isEnabled() - - -def test_toolbar_run_button_is_labelled(make_window): - window = _show(make_window(), (1400, 880)) - label = window.toolbar.btn_run.text() - assert label.strip() - assert label != "▶" - window.toolbar.set_run_checked(True) - assert window.toolbar.btn_run.text() != label - - -def test_theme_switch_updates_panels(make_window): - window = _show(make_window(), (1400, 880)) - window._set_theme("light") - assert window._theme == "light" - window._set_theme("dark") - assert window._theme == "dark" - - -def test_cal_panel_records_and_lists_samples(make_window): - window = _show(make_window(), (1400, 880)) - cal = window.cal_panel - cal.set_running(True) - assert cal.btn_record.isEnabled() - assert cal.btn_record.text() - - from orisys_app_shared.force.calibration_io import CalSample - - cal._store.upsert("fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02)) - cal._refresh_table() - assert cal.table.rowCount() == 1 - assert not cal.empty_hint.isVisible() - - -def test_force_plots_render_demo_data(make_window): - window = _show(make_window(), (1440, 880)) - for i in range(60): - window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N") - from PyQt5.QtWidgets import QApplication - - QApplication.processEvents() - assert window.force_plots.history.sample_count > 0 - assert all(label.text() for label in window.force_plots.value_labels) diff --git a/apps/customer/tools/ui_check.py b/apps/customer/tools/ui_check.py deleted file mode 100644 index 10d0d50..0000000 --- a/apps/customer/tools/ui_check.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env python3 -"""Dependency-free smoke run for the customer UI. - -``pytest`` is the canonical runner (``apps/customer/tests``), but this script -needs nothing beyond the app's own runtime dependencies, so it can be used in -minimal environments: - - python tools/ui_check.py - -It builds the real main window offscreen and asserts the layout, theming and -status-feedback invariants that the UI work depends on. -""" - -from __future__ import annotations - -import os -import sys -from pathlib import Path - -APP_ROOT = Path(__file__).resolve().parents[1] -REPO_ROOT = APP_ROOT.parents[1] -for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"): - if _path.is_dir() and str(_path) not in sys.path: - sys.path.insert(0, str(_path)) - -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: - _fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" - if _fonts.is_dir(): - os.environ["QT_QPA_FONTDIR"] = str(_fonts) -os.chdir(APP_ROOT) - -FAILURES: list[str] = [] -CHECKS = 0 - - -def check(condition: bool, message: str) -> None: - global CHECKS - CHECKS += 1 - if not condition: - FAILURES.append(message) - - -def section(name: str) -> None: - print(f"\n== {name} ==") - - -def main() -> int: - from PyQt5.QtWidgets import QApplication - - from orisys_app_shared.force.calibration_io import CalSample - from orisys_app_shared.i18n import set_locale - from orisys_app_shared.theme import build_qss, load_colors - from orisys_customer.app_window import CustomerMainWindow - - app = QApplication.instance() or QApplication(sys.argv) - set_locale("zh_CN") - - section("theme tokens") - dark = set(load_colors("dark")) - light = set(load_colors("light")) - check(dark == light, f"token keys differ: {sorted(dark ^ light)}") - for theme in ("dark", "light"): - qss = build_qss(theme) - check(len(qss) > 1000, f"{theme} qss suspiciously short") - check( - load_colors(theme)["arrow_canvas"] != load_colors(theme)["accent"], - f"{theme}: arrow canvas collides with accent", - ) - check("QProgressBar::chunk" in qss, f"{theme}: progress bar not styled") - check("QHeaderView::section" in qss, f"{theme}: table header not styled") - check("QRadioButton::indicator" in qss, f"{theme}: radio not styled") - check("QScrollBar::handle" in qss, f"{theme}: scrollbar not styled") - - scene_count = 0 - for theme in ("dark", "light"): - for sensor in ("box", "finger"): - section(f"window {theme}/{sensor}") - scene_count += 1 - app.setStyleSheet(build_qss(theme)) - window = CustomerMainWindow( - prefs={ - "language": "zh_CN", - "theme": theme, - "sensor_type": sensor, - "show_cal_panel": True, - } - ) - window.resize(1440, 880) - window.show() - app.processEvents() - - check(window.windowTitle() != "", "window title empty") - check( - window.status_chip.text() != "", - "status chip has no phase text", - ) - check( - window.toolbar.btn_run.text().strip() not in ("", "▶"), - f"run button is unlabelled: {window.toolbar.btn_run.text()!r}", - ) - - panels = [ - window.mesh3d_panel, - window.arrow_panel, - window.force_plots, - window.cal_panel, - ] - for panel, width in zip(panels, window.content_splitter.sizes()): - if panel.isVisible(): - check( - width >= panel.minimumWidth(), - f"{type(panel).__name__} width {width} < min {panel.minimumWidth()}", - ) - - check( - window.force_plots.width() >= window.force_plots.minimumWidth(), - "force plots narrower than its minimum", - ) - - # value cards must keep their text visible - for card, label in zip( - window.force_plots.value_cards, window.force_plots.value_labels - ): - check( - label.height() >= label.fontMetrics().height(), - "value card text is clipped", - ) - check(label.text() != "", "value card is empty") - - # demo data flows through the plots and the calibration table - for i in range(90): - window.force_plots.push(i / 30.0, float(i % 7), -1.0, 1.0, unit="N") - app.processEvents() - check( - window.force_plots.history.sample_count > 0, - "plots did not accept samples", - ) - - window.cal_panel.set_running(True) - check(window.cal_panel.btn_record.isEnabled(), "record button stays disabled") - window.cal_panel._store.upsert( - "fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02) - ) - window.cal_panel._refresh_table() - check(window.cal_panel.table.rowCount() == 1, "calibration row missing") - check( - not window.cal_panel.empty_hint.isVisible(), - "empty hint still visible with rows", - ) - - window._set_phase("running") - check("status_running" not in window.status_chip.text(), "chip not localized") - window._set_theme("light" if theme == "dark" else "dark") - app.processEvents() - - window._persist_prefs() - check( - len(window.prefs.get("splitter_sizes", [])) == 4, - "splitter sizes not persisted", - ) - window.close() - app.processEvents() - - section("small window") - app.setStyleSheet(build_qss("dark")) - window = CustomerMainWindow( - prefs={ - "language": "en_US", - "theme": "dark", - "sensor_type": "finger", - "show_cal_panel": True, - "show_finger_3d": True, - } - ) - window.resize(1280, 720) - window.show() - app.processEvents() - for panel, width in zip( - [ - window.mesh3d_panel, - window.arrow_panel, - window.force_plots, - window.cal_panel, - ], - window.content_splitter.sizes(), - ): - if panel.isVisible(): - check(width >= panel.minimumWidth(), f"1280px: {type(panel).__name__} squeezed") - oversize = [] - for child in window.findChildren(object): - if not hasattr(child, "isVisible") or not child.isVisible(): - continue - if not hasattr(child, "geometry"): - continue - parent = child.parentWidget() - if parent is None or not parent.isVisible(): - continue - geometry = child.geometry() - if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1: - oversize.append(type(child).__name__) - check(not oversize, f"widgets overflow their parent: {oversize}") - window.close() - app.processEvents() - - print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}") - for failure in FAILURES: - print(f" FAIL: {failure}") - return 1 if FAILURES else 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/apps/customer/tools/ui_snapshot.py b/apps/customer/tools/ui_snapshot.py deleted file mode 100644 index 735311e..0000000 --- a/apps/customer/tools/ui_snapshot.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -"""Render the customer UI offscreen to PNG files for visual review. - -The tool never opens a camera and never starts acquisition: it builds the real -main window, optionally injects deterministic demo data, then grabs the window. - -Examples --------- - python tools/ui_snapshot.py --theme dark --out out_dark.png - python tools/ui_snapshot.py --theme light --sensor finger \ - --panels cal,3d --demo --size 1600x900 --out out_light.png -""" - -from __future__ import annotations - -import argparse -import math -import os -import sys -from pathlib import Path - -APP_ROOT = Path(__file__).resolve().parents[1] -REPO_ROOT = APP_ROOT.parents[1] -for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"): - if _path.is_dir() and str(_path) not in sys.path: - sys.path.insert(0, str(_path)) - -# Silent platform must be selected before QApplication exists. -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: - fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" - if fonts.is_dir(): - os.environ["QT_QPA_FONTDIR"] = str(fonts) -os.chdir(APP_ROOT) - - -def _demo_data(window) -> None: - """Fill the plots and the calibration table with deterministic samples.""" - plots = window.force_plots - for i in range(240): - t = i / 30.0 - fn = 6.0 + 5.0 * math.sin(t * 1.4) - fx = 4.0 * math.sin(t * 2.1 + 0.4) - fy = -3.0 + 2.5 * math.cos(t * 1.1) - plots.push(t, fn, fx, fy, unit="N") - - cal = window.cal_panel - cal.set_running(True) - cal.push_raw(9.81, -0.42, 0.10) - for weight, raw, std in ((1.0, 9.72, 0.031), (2.0, 19.51, 0.044), (5.0, 48.90, 0.061)): - from orisys_app_shared.force.calibration_io import CalSample - - cal._store.upsert("fn", CalSample(weight_n=weight, raw_mean=raw, n=180, std=std)) - cal._refresh_table() - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--theme", choices=("dark", "light"), default="dark") - parser.add_argument("--lang", choices=("zh_CN", "en_US"), default="zh_CN") - parser.add_argument("--sensor", choices=("box", "mini", "finger"), default="box") - parser.add_argument( - "--panels", - default="", - help="comma list of optional panels to show: cal,3d", - ) - parser.add_argument("--size", default="1440x880", help="WxH window size") - parser.add_argument("--view", default="arrows_pure") - parser.add_argument("--demo", action="store_true", help="inject demo plot/table data") - parser.add_argument("--out", required=True, help="output PNG path") - args = parser.parse_args() - - try: - width, height = (int(part) for part in args.size.lower().split("x", 1)) - except ValueError: - parser.error("--size must look like 1440x880") - - panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()} - - from PyQt5.QtWidgets import QApplication - - from orisys_app_shared.i18n import set_locale - from orisys_app_shared.theme import build_qss - from orisys_customer.app_window import CustomerMainWindow - - app = QApplication(sys.argv) - set_locale(args.lang) - app.setStyleSheet(build_qss(args.theme)) - - prefs = { - "language": args.lang, - "theme": args.theme, - "sensor_type": args.sensor, - "show_cal_panel": "cal" in panels, - "show_finger_3d": "3d" in panels, - "show_fps": True, - "image_view_mode": args.view, - } - window = CustomerMainWindow(prefs=prefs) - window.resize(width, height) - window.show() - app.processEvents() - - if args.demo: - _demo_data(window) - for _ in range(4): - app.processEvents() - - out = Path(args.out) - out.parent.mkdir(parents=True, exist_ok=True) - ok = window.grab().save(str(out)) - print(f"{'saved' if ok else 'FAILED'} {out} ({width}x{height}, theme={args.theme})") - window.close() - return 0 if ok else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 0411948..5bad345 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -2,7 +2,7 @@ "app": { "title": "Orisys Studio", "about_title": "About", - "about_body": "Orisys customer desktop application\nVersion: {app_version}", + "about_body": "Orisys Studio desktop application\nVersion: {app_version}", "scaffold_hint": "Scaffold shell — acquisition coming later" }, "brand": { @@ -70,7 +70,7 @@ "raw_short": "raw" }, "sensor": { - "type": "Sensor type", + "type": "Product", "config": "Config", "thumb_placeholder": "Product model" }, diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 6e63f21..34db473 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -2,7 +2,7 @@ "app": { "title": "Orisys Studio", "about_title": "关于", - "about_body": "Orisys 客户端桌面应用\n版本: {app_version}", + "about_body": "Orisys Studio 桌面应用\n版本: {app_version}", "scaffold_hint": "脚手架界面 — 采集功能稍后加入" }, "brand": { @@ -70,7 +70,7 @@ "raw_short": "raw" }, "sensor": { - "type": "传感器类型", + "type": "产品", "config": "配置", "thumb_placeholder": "产品型号" }, diff --git a/apps/studio/docs/ui-palette.md b/apps/studio/docs/ui-palette.md index e2d64e7..06ab617 100644 --- a/apps/studio/docs/ui-palette.md +++ b/apps/studio/docs/ui-palette.md @@ -1,4 +1,4 @@ -# Orisys Customer 配色规范 +# Orisys Studio 配色规范 本文件记录客户端的品牌配色体系、它与 UI 设计稿的关系,以及每一处有意偏离设计稿 的理由。所有色值都存放在主题令牌文件里,控件代码中**不允许出现十六进制颜色**: @@ -8,7 +8,7 @@ orisys-app/shared/src/orisys_app_shared/theme/colors_dark.json orisys-app/shared/src/orisys_app_shared/theme/colors_light.json ``` -规则由 `apps/customer/tools/ui_check.py` 的 `audit_palette()` 机械校验,pytest 套件 +规则由 `apps/studio/tools/ui_check.py` 的 `audit_palette()` 机械校验,pytest 套件 (`tests/test_ui_smoke.py::test_palette_rules_hold`)复用它,因此只有一个真值来源。 --- @@ -152,8 +152,8 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00 ```powershell $py = "D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe" -$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\customer\src" -cd D:\orisys_ws\orisys-app\apps\customer +$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src" +cd D:\orisys_ws\orisys-app\apps\studio & $py tools/ui_check.py # 含 palette 规则段落 & $py tools/ui_snapshot.py --theme dark --lang zh_CN --demo --out outputs/_snap_dark.png diff --git a/apps/studio/pyproject.toml b/apps/studio/pyproject.toml index d4f4375..bc738d2 100644 --- a/apps/studio/pyproject.toml +++ b/apps/studio/pyproject.toml @@ -3,9 +3,9 @@ requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] -name = "orisys-customer" +name = "orisys-studio" version = "0.1.0" -description = "Orisys customer desktop app" +description = "Orisys Studio desktop app" requires-python = ">=3.10" authors = [{ name = "Orisys-Tech" }] dependencies = [ @@ -17,7 +17,7 @@ dependencies = [ ] [project.scripts] -orisys-customer = "orisys_customer.main:main" +orisys-studio = "orisys_studio.main:main" [tool.setuptools] package-dir = { "" = "src" } diff --git a/apps/studio/run.py b/apps/studio/run.py index a604305..fa710cd 100644 --- a/apps/studio/run.py +++ b/apps/studio/run.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Launch the Orisys customer app shell.""" +"""Launch the Orisys Studio app shell.""" from __future__ import annotations @@ -18,7 +18,7 @@ import os os.chdir(ROOT) -from orisys_customer.main import main +from orisys_studio.main import main if __name__ == "__main__": raise SystemExit(main()) diff --git a/apps/studio/src/orisys-studio/__init__.py b/apps/studio/src/orisys-studio/__init__.py deleted file mode 100644 index 304e8af..0000000 --- a/apps/studio/src/orisys-studio/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Orisys customer desktop application.""" - -__version__ = "0.1.0" -REQUIRES_SHARED_API = "0.1" diff --git a/apps/studio/src/orisys-studio/ui/__init__.py b/apps/studio/src/orisys-studio/ui/__init__.py deleted file mode 100644 index fe18e0f..0000000 --- a/apps/studio/src/orisys-studio/ui/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""UI package.""" diff --git a/apps/studio/src/orisys-studio/ui/arrow_panel.py b/apps/studio/src/orisys-studio/ui/arrow_panel.py deleted file mode 100644 index f5b1241..0000000 --- a/apps/studio/src/orisys-studio/ui/arrow_panel.py +++ /dev/null @@ -1,189 +0,0 @@ -"""2D image panel: arrows / depth view with mode selector.""" - -from __future__ import annotations - -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QImage, QPixmap, QTransform -from PyQt5.QtWidgets import ( - QComboBox, - QFrame, - QHBoxLayout, - QLabel, - QSizePolicy, - QVBoxLayout, -) - -from orisys_app_shared.i18n import t - -VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth") -_ROTATION_STEPS = (0, 90, 180, 270) - - -def _normalize_rotation_deg(value: object, default: int = 90) -> int: - try: - deg = int(value) % 360 # type: ignore[arg-type] - except (TypeError, ValueError): - return default - if deg not in _ROTATION_STEPS: - deg = int(round(deg / 90.0) * 90) % 360 - return deg - - -class ArrowPanel(QFrame): - view_mode_changed = pyqtSignal(str) - - def __init__(self, parent=None) -> None: - super().__init__(parent) - self.setObjectName("contentPanel") - layout = QVBoxLayout(self) - - header = QHBoxLayout() - self.title = QLabel() - self.title.setObjectName("panelTitle") - self.fps_label = QLabel() - self.fps_label.setObjectName("panelMeta") - self.fps_label.setVisible(False) - self._fps_visible = False - self._fps_value: float | None = None - self._rotation_deg = 90 - self._rotation_enabled = True - self._source_image: QImage | None = None - self.view_combo = QComboBox() - self.view_combo.setMinimumWidth(140) - self.view_combo.currentIndexChanged.connect(self._on_view_index_changed) - header.addWidget(self.title) - header.addStretch(1) - header.addWidget(self.fps_label) - header.addWidget(self.view_combo) - layout.addLayout(header) - - self.image_label = QLabel() - self.image_label.setAlignment(Qt.AlignCenter) - self.image_label.setMinimumSize(240, 180) - self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.image_label.setScaledContents(False) - self.image_label.setWordWrap(True) - layout.addWidget(self.image_label, 1) - self._placeholder = "" - self._populate_view_combo() - self.retranslate() - - def view_mode(self) -> str: - data = self.view_combo.currentData() - return str(data or "arrows_pure") - - def set_view_mode(self, mode: str) -> None: - wanted = str(mode or "arrows_pure") - if wanted not in VIEW_MODES: - wanted = "arrows_pure" - idx = self.view_combo.findData(wanted) - if idx < 0: - idx = 0 - self.view_combo.blockSignals(True) - self.view_combo.setCurrentIndex(idx) - self.view_combo.blockSignals(False) - - def rotation_deg(self) -> int: - return self._rotation_deg - - def set_rotation_deg(self, deg: object) -> None: - self._rotation_deg = _normalize_rotation_deg(deg) - self._repaint_source() - - def set_rotation_enabled(self, enabled: bool) -> None: - wanted = bool(enabled) - if wanted == self._rotation_enabled: - return - self._rotation_enabled = wanted - self._repaint_source() - - def rotate_90_ccw(self) -> int: - if not self._rotation_enabled: - return self._rotation_deg - self.set_rotation_deg(self._rotation_deg + 90) - return self._rotation_deg - - def set_fps_visible(self, visible: bool) -> None: - self._fps_visible = bool(visible) - self._refresh_fps_label() - - def set_fps(self, fps: float | None) -> None: - self._fps_value = None if fps is None else float(fps) - self._refresh_fps_label() - - def _refresh_fps_label(self) -> None: - if not self._fps_visible or self._fps_value is None: - self.fps_label.clear() - self.fps_label.setVisible(False) - return - self.fps_label.setText(f"{self._fps_value:.1f} FPS") - self.fps_label.setVisible(True) - - def _populate_view_combo(self) -> None: - current = self.view_combo.currentData() - self.view_combo.blockSignals(True) - self.view_combo.clear() - for mode in VIEW_MODES: - self.view_combo.addItem("", mode) - if current: - idx = self.view_combo.findData(current) - self.view_combo.setCurrentIndex(max(0, idx)) - self.view_combo.blockSignals(False) - - def _on_view_index_changed(self, _index: int) -> None: - self.view_mode_changed.emit(self.view_mode()) - - def retranslate(self) -> None: - self.title.setText(t("panels.arrow")) - labels = ( - t("panels.view_arrows_pure"), - t("panels.view_arrows_stitched"), - t("panels.view_depth"), - ) - for i, label in enumerate(labels): - if i < self.view_combo.count(): - self.view_combo.setItemText(i, label) - self.view_combo.setToolTip(t("panels.view_mode_tip")) - self.image_label.setToolTip(t("panels.arrow_empty_tip")) - if not self.image_label.pixmap() or self.image_label.pixmap().isNull(): - self._placeholder = t("panels.arrow_empty") - self.image_label.setText(self._placeholder) - - def _effective_rotation_deg(self) -> int: - if not self._rotation_enabled: - return 0 - return self._rotation_deg - - def _repaint_source(self) -> None: - if self._source_image is None or self._source_image.isNull(): - return - self._paint_image(self._source_image) - - def _paint_image(self, image: QImage) -> None: - pix = QPixmap.fromImage(image) - deg = self._effective_rotation_deg() - if deg: - # QTransform.rotate is clockwise on screen pixmaps; negate for CCW. - pix = pix.transformed(QTransform().rotate(-deg), Qt.FastTransformation) - scaled = pix.scaled( - self.image_label.size(), - Qt.KeepAspectRatio, - Qt.SmoothTransformation, - ) - self.image_label.setPixmap(scaled) - self.image_label.setText("") - - def show_frame(self, image: QImage) -> None: - if image.isNull(): - return - self._source_image = image - self._paint_image(image) - - def clear_preview(self, message: str | None = None) -> None: - self._source_image = None - self.image_label.clear() - self.image_label.setPixmap(QPixmap()) - text = message if message is not None else t("panels.arrow_empty") - self._placeholder = text - self.image_label.setText(text) - self.set_fps(None) diff --git a/apps/studio/src/orisys-studio/ui/cal_panel.py b/apps/studio/src/orisys-studio/ui/cal_panel.py deleted file mode 100644 index 3e78ab9..0000000 --- a/apps/studio/src/orisys-studio/ui/cal_panel.py +++ /dev/null @@ -1,440 +0,0 @@ -"""Force calibration sampling panel (FN / FX / FY).""" - -from __future__ import annotations - -import math -import time - -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QFont, QFontDatabase -from PyQt5.QtWidgets import ( - QAbstractItemView, - QButtonGroup, - QDoubleSpinBox, - QFormLayout, - QFrame, - QHBoxLayout, - QHeaderView, - QLabel, - QLineEdit, - QMessageBox, - QProgressBar, - QPushButton, - QRadioButton, - QSizePolicy, - QTableWidget, - QTableWidgetItem, - QVBoxLayout, -) - -from orisys_app_shared.force.calibration_io import ( - CalSample, - CalSessionStore, - ChannelKey, - export_force_calibration_v2, - meta_from_sensor_json, -) -from orisys_app_shared.i18n import t -from orisys_app_shared.paths import calibration_export_dir, sensors_dir -from orisys_app_shared.theme import color - -CHANNEL_LABELS = ("fn", "fx", "fy") - - -def _mono_font() -> str: - families = set(QFontDatabase().families()) - for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"): - if name in families: - return name - return "monospace" - - -class CalPanel(QFrame): - status_message = pyqtSignal(str) - calibration_exported = pyqtSignal(str) # path applied to running sensor - - def __init__(self, parent=None, *, theme: str = "dark") -> None: - super().__init__(parent) - self.setObjectName("contentPanel") - self._theme = theme - self._store = CalSessionStore() - self._channel: ChannelKey = "fn" - self._running = False - self._recording = False - self._record_deadline = 0.0 - self._record_started = 0.0 - self._record_samples: list[float] = [] - self._live_fn = 0.0 - self._live_fx = 0.0 - self._live_fy = 0.0 - self._sensor_type = "box" - self._config_ref: str | None = None - self._record_duration_s = 7.0 - self._mono = _mono_font() - self._mono_font = QFont(self._mono, 10) - - root = QVBoxLayout(self) - root.setSpacing(8) - - title_row = QHBoxLayout() - self.title = QLabel() - self.title.setObjectName("panelTitle") - self.channel_badge = QLabel() - self.channel_badge.setObjectName("unitBadge") - title_row.addWidget(self.title) - title_row.addStretch(1) - title_row.addWidget(self.channel_badge) - root.addLayout(title_row) - - channel_row = QHBoxLayout() - channel_row.setSpacing(10) - self.channel_group = QButtonGroup(self) - self.radio_fn = QRadioButton("FN") - self.radio_fx = QRadioButton("FX") - self.radio_fy = QRadioButton("FY") - self.radio_fn.setChecked(True) - for i, radio in enumerate((self.radio_fn, self.radio_fx, self.radio_fy)): - self.channel_group.addButton(radio, i) - channel_row.addWidget(radio) - channel_row.addStretch(1) - self.channel_group.buttonToggled.connect(self._on_channel_button) - root.addLayout(channel_row) - - form = QFormLayout() - form.setHorizontalSpacing(10) - form.setVerticalSpacing(6) - form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) - self._weight_label = QLabel() - self._weight_label.setObjectName("fieldLabel") - self.weight_spin = QDoubleSpinBox() - self.weight_spin.setRange(0.0, 1e6) - self.weight_spin.setDecimals(4) - self.weight_spin.setSingleStep(0.1) - self.weight_spin.setValue(0.0) - self._duration_label = QLabel() - self._duration_label.setObjectName("fieldLabel") - self.duration_spin = QDoubleSpinBox() - self.duration_spin.setRange(5.0, 10.0) - self.duration_spin.setDecimals(1) - self.duration_spin.setSingleStep(0.5) - self.duration_spin.setValue(7.0) - self.duration_spin.setSuffix(" s") - form.addRow(self._weight_label, self.weight_spin) - form.addRow(self._duration_label, self.duration_spin) - root.addLayout(form) - - rec_row = QHBoxLayout() - self.btn_record = QPushButton() - self.btn_record.setMinimumHeight(32) - self.btn_record.clicked.connect(self._on_record_clicked) - rec_row.addWidget(self.btn_record) - rec_row.addStretch(1) - root.addLayout(rec_row) - - self.progress = QProgressBar() - self.progress.setRange(0, 1000) - self.progress.setValue(0) - self.progress.setTextVisible(True) - root.addWidget(self.progress) - - self.live_label = QLabel() - self.live_label.setWordWrap(True) - self.live_label.setFont(self._mono_font) - root.addWidget(self.live_label) - - self.empty_hint = QLabel() - self.empty_hint.setObjectName("hintText") - self.empty_hint.setWordWrap(True) - root.addWidget(self.empty_hint) - - self.table = QTableWidget(0, 4) - self.table.setSelectionBehavior(QAbstractItemView.SelectRows) - self.table.setSelectionMode(QAbstractItemView.SingleSelection) - self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.table.setAlternatingRowColors(True) - self.table.setShowGrid(False) - header = self.table.horizontalHeader() - header.setSectionResizeMode(QHeaderView.Stretch) - header.setSectionResizeMode(2, QHeaderView.ResizeToContents) - self.table.verticalHeader().setVisible(False) - self.table.verticalHeader().setDefaultSectionSize(26) - root.addWidget(self.table, 1) - - sn_row = QHBoxLayout() - self._sn_label = QLabel() - self._sn_label.setObjectName("fieldLabel") - self.sn_edit = QLineEdit() - self.sn_edit.setPlaceholderText("") - sn_row.addWidget(self._sn_label) - sn_row.addWidget(self.sn_edit, 1) - root.addLayout(sn_row) - - actions = QHBoxLayout() - actions.setSpacing(6) - self.btn_delete = QPushButton() - self.btn_clear_channel = QPushButton() - self.btn_clear_all = QPushButton() - self.btn_export = QPushButton() - self.btn_export.setObjectName("primaryButton") - for button in (self.btn_delete, self.btn_clear_channel, self.btn_clear_all): - # The action row must survive a narrow three-panel layout. - button.setProperty("compact", "true") - button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) - self.btn_delete.clicked.connect(self._on_delete_row) - self.btn_clear_channel.clicked.connect(self._on_clear_channel) - self.btn_clear_all.clicked.connect(self._on_clear_all) - self.btn_export.clicked.connect(self._on_export) - actions.addWidget(self.btn_delete) - actions.addWidget(self.btn_clear_channel) - actions.addWidget(self.btn_clear_all) - actions.addStretch(1) - actions.addWidget(self.btn_export) - root.addLayout(actions) - - self._refresh_enabled() - self._refresh_table() - self.set_theme(theme) - self.retranslate() - - # ------------------------------------------------------------------ API - def set_theme(self, theme: str) -> None: - self._theme = theme - self._render_live_label() - - def set_running(self, running: bool) -> None: - self._running = bool(running) - if not self._running and self._recording: - self._cancel_recording() - self._refresh_enabled() - - def set_meta_context(self, *, sensor_type: str, config_ref: str | None) -> None: - self._sensor_type = str(sensor_type or "box") - self._config_ref = config_ref - - def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None: - self._live_fn = float(fn) - self._live_fx = float(fx) - self._live_fy = float(fy) - self._render_live_label() - if not self._recording: - return - now = time.perf_counter() - value = {"fn": self._live_fn, "fx": self._live_fx, "fy": self._live_fy}[self._channel] - self._record_samples.append(float(value)) - span = max(1e-6, self._record_deadline - self._record_started) - frac = min(1.0, max(0.0, (now - self._record_started) / span)) - self.progress.setValue(int(frac * 1000)) - self.progress.setFormat(f"{now - self._record_started:.1f} / {span:.1f} s") - if now >= self._record_deadline: - self._finish_recording() - - def retranslate(self) -> None: - self.title.setText(t("panels.cal")) - self._weight_label.setText(t("cal.weight")) - self._duration_label.setText(t("cal.duration")) - self.btn_record.setText( - t("cal.recording_stop") if self._recording else t("cal.record") - ) - self.btn_delete.setText(t("cal.delete_row")) - self.btn_clear_channel.setText(t("cal.clear_channel")) - self.btn_clear_all.setText(t("cal.clear_all")) - self.btn_export.setText(t("cal.export")) - self._sn_label.setText(t("cal.sn")) - self.sn_edit.setPlaceholderText(t("cal.sn_placeholder")) - self.empty_hint.setText(t("cal.empty_hint")) - headers = ( - t("cal.col_weight"), - t("cal.col_raw"), - t("cal.col_n"), - t("cal.col_std"), - ) - for i, text in enumerate(headers): - item = self.table.horizontalHeaderItem(i) - if item is None: - item = QTableWidgetItem(text) - self.table.setHorizontalHeaderItem(i, item) - else: - item.setText(text) - self._refresh_channel_badge() - self._render_live_label() - self._refresh_enabled() - - # ------------------------------------------------------------- internals - def _refresh_channel_badge(self) -> None: - color_hex = color(self._theme, f"plot_{self._channel}") - self.channel_badge.setText(self._channel.upper()) - self.channel_badge.setStyleSheet( - f"QLabel#unitBadge {{ color: {color_hex};" - f" border: 1px solid {color(self._theme, 'card_border')};" - f" background-color: {color(self._theme, 'card_bg')}; }}" - ) - - def _on_channel_button(self, button, checked: bool) -> None: - if not checked: - return - mapping = {self.radio_fn: "fn", self.radio_fx: "fx", self.radio_fy: "fy"} - key = mapping.get(button) - if key is None: - return - if self._recording: - self._cancel_recording() - self._channel = key # type: ignore[assignment] - self._refresh_channel_badge() - self._refresh_table() - - def _refresh_enabled(self) -> None: - can_record = self._running and not self._recording - self.btn_record.setEnabled(can_record) - self.btn_record.setObjectName("dangerButton" if self._recording else "primaryButton") - self.btn_record.style().unpolish(self.btn_record) - self.btn_record.style().polish(self.btn_record) - self.weight_spin.setEnabled(not self._recording) - self.duration_spin.setEnabled(not self._recording) - for radio in (self.radio_fn, self.radio_fx, self.radio_fy): - radio.setEnabled(not self._recording) - self.btn_export.setEnabled(not self._recording) - if self._recording: - self.progress.setVisible(True) - record_hint = t("cal.record_disabled") if not self._running else "" - if not self._recording: - self.btn_record.setToolTip(record_hint) - - def _render_live_label(self) -> None: - colors = { - "fn": color(self._theme, "plot_fn"), - "fx": color(self._theme, "plot_fx"), - "fy": color(self._theme, "plot_fy"), - } - parts = [] - for key, value in ( - ("fn", self._live_fn), - ("fx", self._live_fx), - ("fy", self._live_fy), - ): - parts.append( - f"{key.upper()}" - f" {value:.3f}" - ) - self.live_label.setText( - f"" - f"{t('cal.live_raw_label')} " + " ".join(parts) - ) - - def _on_record_clicked(self) -> None: - if self._recording: - self._cancel_recording() - self.status_message.emit(t("cal.record_cancelled")) - return - if not self._running: - return - self._recording = True - self._record_samples = [] - self._record_duration_s = float(self.duration_spin.value()) - self._record_started = time.perf_counter() - self._record_deadline = self._record_started + self._record_duration_s - self.progress.setVisible(True) - self.progress.setValue(0) - self.progress.setFormat(f"0.0 / {self._record_duration_s:.1f} s") - self._refresh_enabled() - self.status_message.emit(t("cal.recording")) - - def _cancel_recording(self) -> None: - self._recording = False - self._record_samples = [] - self.progress.setValue(0) - self.progress.setFormat("") - self._refresh_enabled() - - def _finish_recording(self) -> None: - samples = list(self._record_samples) - self._recording = False - self._record_samples = [] - self.progress.setValue(1000) - self.progress.setFormat("") - self._refresh_enabled() - if len(samples) < 2: - self.status_message.emit(t("cal.record_failed_few")) - return - mean = sum(samples) / len(samples) - var = sum((x - mean) ** 2 for x in samples) / len(samples) - std = math.sqrt(var) - sample = CalSample( - weight_n=float(self.weight_spin.value()), - raw_mean=mean, - n=len(samples), - std=std, - ) - self._store.upsert(self._channel, sample) - self._refresh_table() - self.status_message.emit( - t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}") - ) - - def _refresh_table(self) -> None: - rows = self._store.channel(self._channel) - self.table.setRowCount(len(rows)) - for r, sample in enumerate(rows): - values = ( - f"{sample.weight_n:g}", - f"{sample.raw_mean:.4f}", - str(sample.n), - f"{sample.std:.4f}", - ) - for c, text in enumerate(values): - item = QTableWidgetItem(text) - item.setFlags(item.flags() & ~Qt.ItemIsEditable) - if c != 2: - item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter)) - else: - item.setTextAlignment(int(Qt.AlignCenter)) - self.table.setItem(r, c, item) - self.empty_hint.setVisible(not rows) - - def _on_delete_row(self) -> None: - row = self.table.currentRow() - if row < 0: - return - self._store.remove_at(self._channel, row) - self._refresh_table() - - def _on_clear_channel(self) -> None: - self._store.clear(self._channel) - self._refresh_table() - - def _on_clear_all(self) -> None: - self._store.clear() - self._refresh_table() - - def _on_export(self) -> None: - meta = meta_from_sensor_json(self._config_ref) - meta["sensor_type"] = self._sensor_type - meta["sn"] = self.sn_edit.text().strip() - meta["record_duration_s"] = float(self.duration_spin.value()) - local_bundle = sensors_dir() / self._sensor_type - if not local_bundle.is_dir(): - local_bundle = None - try: - primary, local_path, warnings = export_force_calibration_v2( - self._store, - export_dir=calibration_export_dir(), - sensor_type=self._sensor_type, - meta=meta, - local_bundle_dir=local_bundle, - ) - except ValueError as exc: - QMessageBox.warning(self, t("panels.cal"), str(exc)) - return - except Exception as exc: - QMessageBox.warning(self, t("panels.cal"), t("cal.export_failed", error=str(exc))) - return - - msg = t("cal.export_ok", path=str(primary)) - if local_path is not None: - msg += "\n" + t("cal.export_local", path=str(local_path)) - if warnings: - msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings)) - self.status_message.emit(msg) - # Prefer the bundle file for live apply so the next Sensor() also sees it. - apply_path = local_path if local_path is not None else primary - self.calibration_exported.emit(str(apply_path)) - QMessageBox.information(self, t("panels.cal"), msg) diff --git a/apps/studio/src/orisys-studio/ui/force_plots.py b/apps/studio/src/orisys-studio/ui/force_plots.py deleted file mode 100644 index 3292145..0000000 --- a/apps/studio/src/orisys-studio/ui/force_plots.py +++ /dev/null @@ -1,288 +0,0 @@ -"""FN / FX / FY force charts with live value labels.""" - -from __future__ import annotations - -import numpy as np -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QColor, QFont, QFontDatabase -from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout - -from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ -from orisys_app_shared.force import MetricHistory -from orisys_app_shared.force.history import DEFAULT_MAX_FPS -from orisys_app_shared.i18n import t -from orisys_app_shared.theme import color - -try: - import pyqtgraph as pg -except ImportError as exc: # pragma: no cover - raise ImportError("pyqtgraph is required for force charts") from exc - - -CHANNEL_KEYS = ("fn", "fx", "fy") -CHANNEL_LABELS = ("FN", "FX", "FY") -# Theme token that colors each channel curve / label, per index. -CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy") - - -def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]: - if hi <= lo: - return [(lo, f"{lo:g}")] - step = (hi - lo) / max(1, count - 1) - return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)] - - -def format_force(value: float, unit: str) -> str: - """Render one force reading for the value cards.""" - if unit == "N": - return f"{value:.3f}" - return f"{value:.1f}" - - -def unit_display(unit: str) -> str: - return t("units.newton_short") if unit == "N" else t("units.raw_short") - - -def _mono_font(point_size: int = 9, *, bold: bool = False) -> QFont: - """Pick the first available monospace face for stable digit alignment.""" - families = set(QFontDatabase().families()) - chosen = "monospace" - for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"): - if name in families: - chosen = name - break - font = QFont(chosen, point_size) - font.setBold(bold) - return font - - -class ForcePlotsPanel(QFrame): - def __init__(self, parent=None, *, theme: str = "dark") -> None: - super().__init__(parent) - self.setObjectName("contentPanel") - self._theme = theme - self._fn_max = 20.0 - self._shear_max = 20.0 - self._fixed_newton = True - self._unit = "raw" - self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0) - self.history = MetricHistory( - channels=3, - window_sec=4.0, - sample_rate_hz=FORCE_SAMPLE_RATE_HZ, - lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ, - y_axis_min_span=0.5, - max_fps=DEFAULT_MAX_FPS, - ) - - root = QVBoxLayout(self) - root.setContentsMargins(10, 8, 10, 10) - root.setSpacing(6) - - header = QHBoxLayout() - header.setSpacing(6) - self.title = QLabel() - self.title.setObjectName("panelTitle") - header.addWidget(self.title) - header.addStretch(1) - self.unit_badge = QLabel() - self.unit_badge.setObjectName("unitBadge") - header.addWidget(self.unit_badge) - root.addLayout(header) - - self.plot_frame = QFrame() - self.plot_frame.setObjectName("cardPanel") - plot_layout = QVBoxLayout(self.plot_frame) - plot_layout.setContentsMargins(4, 4, 4, 4) - self.plot_widget = pg.GraphicsLayoutWidget() - self.plot_widget.ci.setSpacing(2) - plot_layout.addWidget(self.plot_widget) - root.addWidget(self.plot_frame, 1) - - self.plots = [] - self.curves = [] - pen = pg.mkPen(color(theme, CHANNEL_TOKENS[0]), width=2) - for i, _name in enumerate(CHANNEL_LABELS): - plot = self.plot_widget.addPlot(row=i, col=0) - plot.showGrid(x=True, y=True, alpha=0.25) - plot.setMouseEnabled(x=False, y=False) - if i > 0: - plot.setXLink(self.plots[0]) - curve = plot.plot(pen=pen) - self.plots.append(plot) - self.curves.append(curve) - - values_row = QHBoxLayout() - values_row.setSpacing(6) - self.value_cards: list[QFrame] = [] - self.value_labels: list[QLabel] = [] - self.channel_badges: list[QLabel] = [] - self.value_units: list[QLabel] = [] - for label in CHANNEL_LABELS: - card, badge, value_label, unit_label = self._build_value_card(label) - values_row.addWidget(card, 1) - self.value_cards.append(card) - self.channel_badges.append(badge) - self.value_labels.append(value_label) - self.value_units.append(unit_label) - root.addLayout(values_row) - - self.set_theme(theme) - self._apply_axis_mode() - self.retranslate() - - def _build_value_card(self, channel: str) -> tuple[QFrame, QLabel, QLabel, QLabel]: - card = QFrame() - card.setObjectName("cardPanel") - layout = QVBoxLayout(card) - layout.setContentsMargins(7, 4, 7, 4) - layout.setSpacing(1) - - top = QHBoxLayout() - top.setSpacing(6) - badge = QLabel(channel) - badge.setObjectName("unitBadge") - top.addWidget(badge) - top.addStretch(1) - unit_label = QLabel() - unit_label.setObjectName("hintText") - top.addWidget(unit_label) - - value_label = QLabel() - value_label.setObjectName("valueCard") - value_label.setFixedHeight(20) - value_label.setContentsMargins(6, 0, 6, 0) - value_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - value_label.setFont(_mono_font(9, bold=True)) - - layout.addLayout(top) - layout.addWidget(value_label) - return card, badge, value_label, unit_label - - def set_theme(self, theme: str) -> None: - self._theme = theme - bg = color(theme, "plot_bg") - text = color(theme, "text_primary") - self.plot_widget.setBackground(bg) - axis_pen = pg.mkPen(text) - for i, plot in enumerate(self.plots): - try: - plot.getViewBox().setBackgroundColor(QColor(bg)) - except Exception: - pass - for axis_name in ("left", "bottom"): - axis = plot.getAxis(axis_name) - axis.setPen(axis_pen) - axis.setTextPen(axis_pen) - plot.showGrid(x=True, y=True, alpha=0.25) - # Color each channel axis label to match its curve pen. - plot.setLabel("left", CHANNEL_LABELS[i], color=color(theme, CHANNEL_TOKENS[i])) - pens = tuple(color(theme, token) for token in CHANNEL_TOKENS) - for curve, pen in zip(self.curves, pens): - curve.setPen(pg.mkPen(pen, width=2)) - for badge, token in zip(self.channel_badges, CHANNEL_TOKENS): - badge.setStyleSheet( - f"QLabel#unitBadge {{ color: {color(theme, token)};" - f" background-color: {color(theme, 'card_bg')};" - f" border: 1px solid {color(theme, 'card_border')}; }}" - ) - for value_label, token in zip(self.value_labels, CHANNEL_TOKENS): - value_label.setStyleSheet( - f"QLabel#valueCard {{ color: {color(theme, token)}; }}" - ) - self._render_value_cards() - - def set_window_sec(self, sec: float) -> None: - self.history.set_window_sec(sec) - self._clear_curves() - self._apply_axis_mode() - - def set_axis_limits(self, fn_max: float, shear_max: float) -> None: - self._fn_max = max(0.1, float(fn_max)) - self._shear_max = max(0.1, float(shear_max)) - self._apply_axis_mode() - - def set_fixed_newton(self, enabled: bool) -> None: - self._fixed_newton = bool(enabled) - self._apply_axis_mode() - - def reset(self) -> None: - self.history.reset() - self._clear_curves() - self._last_values = (0.0, 0.0, 0.0) - self._unit = "raw" - self._render_value_cards() - self._apply_axis_mode() - - def push( - self, - timestamp: float, - fn: float, - fx: float, - fy: float, - *, - unit: str = "raw", - ) -> None: - self._unit = unit - self._last_values = (float(fn), float(fx), float(fy)) - use_fixed = self._uses_fixed_y() - plot_fn = max(0.0, float(fn)) if use_fixed else float(fn) - self.history.push(timestamp, plot_fn, float(fx), float(fy)) - self._render_value_cards() - state = self.history.state() - if state is None: - return - values = state.values.copy() - if use_fixed: - values[:, 0] = np.maximum(values[:, 0], 0.0) - for i, curve in enumerate(self.curves): - curve.setData(state.times, values[:, i]) - self.plots[i].setXRange(*state.x_range, padding=0.0) - if use_fixed: - ymin, ymax = self._fixed_y_range(i) - self.plots[i].setYRange(ymin, ymax, padding=0.0) - self.plots[i].getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)]) - else: - self.plots[i].getAxis("left").setTicks(None) - self.plots[i].setYRange(*state.y_ranges[i], padding=0.0) - - def _uses_fixed_y(self) -> bool: - return bool(self._fixed_newton) and self._unit == "N" - - def _fixed_y_range(self, channel_index: int) -> tuple[float, float]: - if channel_index == 0: - return 0.0, self._fn_max - return -self._shear_max, self._shear_max - - def _apply_axis_mode(self) -> None: - # Preview fixed newton axes when newton mode is selected, even before samples. - preview_fixed = bool(self._fixed_newton) - use_fixed = self._uses_fixed_y() or (preview_fixed and self.history.sample_count == 0) - for i, plot in enumerate(self.plots): - if use_fixed: - ymin, ymax = self._fixed_y_range(i) - plot.setYRange(ymin, ymax, padding=0.0) - plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)]) - else: - plot.getAxis("left").setTicks(None) - for plot in self.plots: - plot.setXRange(0.0, self.history.window_sec, padding=0.0) - - def _clear_curves(self) -> None: - for curve in self.curves: - curve.setData([], []) - - def _render_value_cards(self) -> None: - fmt = "{value:.3f}" if self._unit == "N" else "{value:.1f}" - for value_label, unit_label, value in zip( - self.value_labels, self.value_units, self._last_values - ): - value_label.setText(fmt.format(value=value)) - unit_label.setText(unit_display(self._unit)) - self.unit_badge.setText(unit_display(self._unit)) - - def retranslate(self) -> None: - self.title.setText(t("panels.plots")) - for badge, label in zip(self.channel_badges, CHANNEL_LABELS): - badge.setText(label) - self._render_value_cards() diff --git a/apps/studio/src/orisys-studio/ui/mesh3d_panel.py b/apps/studio/src/orisys-studio/ui/mesh3d_panel.py deleted file mode 100644 index 7442a30..0000000 --- a/apps/studio/src/orisys-studio/ui/mesh3d_panel.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Placeholder panel for finger 3D mesh view.""" - -from __future__ import annotations - -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QFrame, QLabel, QVBoxLayout - -from orisys_app_shared.i18n import t - - -class Mesh3dPanel(QFrame): - def __init__(self, parent=None) -> None: - super().__init__(parent) - self.setObjectName("contentPanel") - layout = QVBoxLayout(self) - - header = QVBoxLayout() - header.setSpacing(2) - self.title = QLabel() - self.title.setObjectName("panelTitle") - header.addWidget(self.title) - layout.addLayout(header) - - self.body = QLabel() - self.body.setObjectName("hintText") - self.body.setAlignment(Qt.AlignCenter) - self.body.setWordWrap(True) - layout.addWidget(self.body, 1) - - self.retranslate() - - def retranslate(self) -> None: - self.title.setText(t("panels.mesh3d")) - self.body.setText(t("panels.mesh3d_empty")) - self.body.setToolTip(t("panels.mesh3d_empty_tip")) diff --git a/apps/studio/src/orisys-studio/ui/plot_settings_dialog.py b/apps/studio/src/orisys-studio/ui/plot_settings_dialog.py deleted file mode 100644 index 198accf..0000000 --- a/apps/studio/src/orisys-studio/ui/plot_settings_dialog.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Modal dialog for force-plot window and Y-axis limits.""" - -from __future__ import annotations - -from PyQt5.QtWidgets import ( - QDialog, - QDialogButtonBox, - QDoubleSpinBox, - QFormLayout, - QHBoxLayout, - QLabel, - QPushButton, - QVBoxLayout, -) - -from orisys_app_shared.i18n import t - -DEFAULT_PLOT_WINDOW_SEC = 4.0 -DEFAULT_PLOT_FN_MAX = 20.0 -DEFAULT_PLOT_SHEAR_MAX = 20.0 - - -class PlotSettingsDialog(QDialog): - def __init__( - self, - parent=None, - *, - window_sec: float = DEFAULT_PLOT_WINDOW_SEC, - fn_max: float = DEFAULT_PLOT_FN_MAX, - shear_max: float = DEFAULT_PLOT_SHEAR_MAX, - units: str = "newton", - ) -> None: - super().__init__(parent) - self.setModal(True) - self.resize(360, 200) - - root = QVBoxLayout(self) - form = QFormLayout() - - self.window_label = QLabel() - self.window_spin = QDoubleSpinBox() - self.window_spin.setRange(1.0, 30.0) - self.window_spin.setSingleStep(1.0) - self.window_spin.setDecimals(0) - self.window_spin.setSuffix(" s") - self.window_spin.setValue(float(window_sec)) - - self.fn_max_label = QLabel() - self.fn_max_spin = QDoubleSpinBox() - self.fn_max_spin.setRange(1.0, 100.0) - self.fn_max_spin.setSingleStep(1.0) - self.fn_max_spin.setDecimals(0) - self.fn_max_spin.setSuffix(" N") - self.fn_max_spin.setValue(float(fn_max)) - - self.shear_max_label = QLabel() - self.shear_max_spin = QDoubleSpinBox() - self.shear_max_spin.setRange(1.0, 100.0) - self.shear_max_spin.setSingleStep(1.0) - self.shear_max_spin.setDecimals(0) - self.shear_max_spin.setSuffix(" N") - self.shear_max_spin.setValue(float(shear_max)) - - form.addRow(self.window_label, self.window_spin) - form.addRow(self.fn_max_label, self.fn_max_spin) - form.addRow(self.shear_max_label, self.shear_max_spin) - root.addLayout(form) - - newton = str(units or "newton").lower().startswith("n") - self.fn_max_spin.setEnabled(newton) - self.shear_max_spin.setEnabled(newton) - self.fn_max_label.setEnabled(newton) - self.shear_max_label.setEnabled(newton) - - buttons_row = QHBoxLayout() - self.btn_reset = QPushButton() - self.btn_reset.clicked.connect(self._reset_defaults) - buttons_row.addWidget(self.btn_reset) - buttons_row.addStretch(1) - self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - self.button_box.accepted.connect(self.accept) - self.button_box.rejected.connect(self.reject) - buttons_row.addWidget(self.button_box) - root.addLayout(buttons_row) - - self.retranslate() - - def values(self) -> tuple[float, float, float]: - return ( - float(self.window_spin.value()), - float(self.fn_max_spin.value()), - float(self.shear_max_spin.value()), - ) - - def _reset_defaults(self) -> None: - self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC) - self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX) - self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX) - - def retranslate(self) -> None: - self.setWindowTitle(t("dialog.plot_settings_title")) - self.window_label.setText(t("dialog.plot_window")) - self.window_spin.setToolTip(t("dialog.plot_window_tip")) - self.fn_max_label.setText(t("dialog.plot_fn_max")) - self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip")) - self.shear_max_label.setText(t("dialog.plot_shear_max")) - self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip")) - self.btn_reset.setText(t("dialog.reset_defaults")) - ok_btn = self.button_box.button(QDialogButtonBox.Ok) - cancel_btn = self.button_box.button(QDialogButtonBox.Cancel) - if ok_btn is not None: - ok_btn.setText(t("dialog.ok")) - if cancel_btn is not None: - cancel_btn.setText(t("dialog.cancel")) diff --git a/apps/studio/src/orisys-studio/ui/sensor_picker.py b/apps/studio/src/orisys-studio/ui/sensor_picker.py deleted file mode 100644 index b781c0d..0000000 --- a/apps/studio/src/orisys-studio/ui/sensor_picker.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Sensor type + config picker with product badge.""" - -from __future__ import annotations - -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtWidgets import ( - QComboBox, - QFormLayout, - QFrame, - QHBoxLayout, - QLabel, - QSizePolicy, - QVBoxLayout, - QWidget, -) - -from orisys_app_shared.i18n import t -from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types - -THUMB_SIZE = 56 -CONTROL_HEIGHT = 30 -MAX_CONTROL_WIDTH = 220 - - -class SensorPickerWidget(QWidget): - sensor_type_changed = pyqtSignal(str) - config_changed = pyqtSignal(str) - - def __init__(self, parent=None) -> None: - super().__init__(parent) - layout = QHBoxLayout(self) - layout.setContentsMargins(8, 6, 8, 6) - layout.setSpacing(10) - - self.thumb = QFrame() - self.thumb.setObjectName("cardPanel") - self.thumb.setFixedSize(THUMB_SIZE, THUMB_SIZE) - thumb_layout = QVBoxLayout(self.thumb) - thumb_layout.setContentsMargins(2, 2, 2, 2) - thumb_layout.setSpacing(0) - self.thumb_label = QLabel() - self.thumb_label.setObjectName("unitBadge") - self.thumb_label.setAlignment(Qt.AlignCenter) - self.thumb_label.setWordWrap(False) - thumb_layout.addWidget(self.thumb_label) - - form = QFormLayout() - form.setContentsMargins(0, 0, 0, 0) - form.setHorizontalSpacing(8) - form.setVerticalSpacing(6) - form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) - form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) - - self.type_label = QLabel() - self.type_label.setObjectName("fieldLabel") - self.type_combo = QComboBox() - self.type_combo.setMinimumHeight(CONTROL_HEIGHT) - self.type_combo.setMaximumWidth(MAX_CONTROL_WIDTH) - self.type_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - for name in list_sensor_types(): - self.type_combo.addItem(name, name) - self.type_combo.currentIndexChanged.connect(self._on_type_index_changed) - - self.config_label = QLabel() - self.config_label.setObjectName("fieldLabel") - self.config_combo = QComboBox() - self.config_combo.setMinimumHeight(CONTROL_HEIGHT) - self.config_combo.setMaximumWidth(MAX_CONTROL_WIDTH) - self.config_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.config_combo.currentIndexChanged.connect(self._on_config_index_changed) - - form.addRow(self.type_label, self.type_combo) - form.addRow(self.config_label, self.config_combo) - - layout.addWidget(self.thumb) - layout.addLayout(form, 1) - self._reload_configs() - self._refresh_thumb() - self.retranslate() - - def sensor_type(self) -> str: - data = self.type_combo.currentData() - return str(data or self.type_combo.currentText() or "box") - - def config_name(self) -> str: - data = self.config_combo.currentData() - if data is None: - return str(self.config_combo.currentText() or "") - return str(data) - - def set_sensor_type(self, sensor_type: str) -> None: - wanted = str(sensor_type or "box") - idx = self.type_combo.findData(wanted) - if idx < 0: - idx = self.type_combo.findText(wanted) - if idx < 0: - idx = 0 - self.type_combo.blockSignals(True) - self.type_combo.setCurrentIndex(idx) - self.type_combo.blockSignals(False) - self._reload_configs() - self._refresh_thumb() - - def set_config_name(self, config_name: str) -> None: - wanted = str(config_name or "") - idx = self.config_combo.findData(wanted) - if idx < 0 and wanted: - idx = self.config_combo.findText(wanted) - if idx < 0: - idx = 0 - self.config_combo.blockSignals(True) - self.config_combo.setCurrentIndex(max(0, idx)) - self.config_combo.blockSignals(False) - - def set_picker_enabled(self, enabled: bool) -> None: - """Enable/disable both pickers (used while a session is running).""" - self.type_combo.setEnabled(bool(enabled)) - self.config_combo.setEnabled(bool(enabled)) - - def _reload_configs(self) -> None: - self.config_combo.blockSignals(True) - self.config_combo.clear() - for display, key in list_configs_for_type(self.sensor_type()): - self.config_combo.addItem(display, key) - if self.config_combo.count() == 0: - self.config_combo.addItem(self.sensor_type(), self.sensor_type()) - self.config_combo.blockSignals(False) - - def _refresh_thumb(self) -> None: - self.thumb_label.setText(self.sensor_type().upper()) - - def _on_type_index_changed(self, _index: int) -> None: - self._reload_configs() - self._refresh_thumb() - self.sensor_type_changed.emit(self.sensor_type()) - self.config_changed.emit(self.config_name()) - - def _on_config_index_changed(self, _index: int) -> None: - self.config_changed.emit(self.config_name()) - - def retranslate(self) -> None: - self.type_label.setText(t("sensor.type")) - self.config_label.setText(t("sensor.config")) - self.thumb.setToolTip(t("sensor.thumb_placeholder")) - self._refresh_thumb() diff --git a/apps/studio/src/orisys-studio/ui/toolbar.py b/apps/studio/src/orisys-studio/ui/toolbar.py deleted file mode 100644 index 1fa7849..0000000 --- a/apps/studio/src/orisys-studio/ui/toolbar.py +++ /dev/null @@ -1,348 +0,0 @@ -"""Top toolbar: source, camera/video, run/reset, units.""" - -from __future__ import annotations - -from pathlib import Path - -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtWidgets import ( - QComboBox, - QFrame, - QHBoxLayout, - QLabel, - QLineEdit, - QPushButton, - QScrollArea, - QSizePolicy, - QSpinBox, - QStackedWidget, - QVBoxLayout, - QWidget, -) - -from orisys_app_shared.i18n import t - -CONTROL_HEIGHT = 30 -BUTTON_HEIGHT = 32 - - -def _separator() -> QFrame: - line = QFrame() - line.setObjectName("sectionSeparator") - line.setFrameShape(QFrame.VLine) - line.setFixedWidth(1) - line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) - return line - - -def _group(*widgets: QWidget) -> QWidget: - box = QWidget() - layout = QHBoxLayout(box) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(8) - for widget in widgets: - layout.addWidget(widget) - return box - - -class ToolbarWidget(QWidget): - search_clicked = pyqtSignal() - preview_toggled = pyqtSignal(bool) - camera_id_changed = pyqtSignal(int) - source_mode_changed = pyqtSignal(str) - browse_video_clicked = pyqtSignal() - run_toggled = pyqtSignal(bool) - reset_clicked = pyqtSignal() - units_changed = pyqtSignal(str) - - def __init__(self, parent=None) -> None: - super().__init__(parent) - self._run_tooltip_reason = "" - self._run_available = True - outer = QVBoxLayout(self) - outer.setContentsMargins(0, 0, 0, 0) - outer.setSpacing(0) - - self._scroll = QScrollArea() - self._scroll.setWidgetResizable(True) - self._scroll.setFrameShape(QFrame.NoFrame) - self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - outer.addWidget(self._scroll) - - content = QWidget() - self._scroll.setWidget(content) - layout = QHBoxLayout(content) - layout.setContentsMargins(2, 2, 2, 2) - layout.setSpacing(10) - - # --- source group ------------------------------------------------- - self.source_label = QLabel() - self.source_label.setObjectName("fieldLabel") - self.source_combo = QComboBox() - self.source_combo.setMinimumWidth(96) - self.source_combo.setMinimumHeight(CONTROL_HEIGHT) - self.source_combo.currentIndexChanged.connect(self._on_source_index_changed) - - self.source_stack = QStackedWidget() - cam_page = QWidget() - cam_layout = QHBoxLayout(cam_page) - cam_layout.setContentsMargins(0, 0, 0, 0) - cam_layout.setSpacing(8) - self.cam_id_label = QLabel() - self.cam_id_label.setObjectName("fieldLabel") - self.cam_id_spin = QSpinBox() - self.cam_id_spin.setRange(0, 20) - self.cam_id_spin.setValue(0) - self.cam_id_spin.setFixedWidth(72) - self.cam_id_spin.setMinimumHeight(CONTROL_HEIGHT) - self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit) - cam_layout.addWidget(self.cam_id_label) - cam_layout.addWidget(self.cam_id_spin) - - video_page = QWidget() - video_layout = QHBoxLayout(video_page) - video_layout.setContentsMargins(0, 0, 0, 0) - video_layout.setSpacing(8) - self.video_edit = QLineEdit() - self.video_edit.setMinimumWidth(180) - self.video_edit.setMinimumHeight(CONTROL_HEIGHT) - self.video_edit.setReadOnly(True) - self.btn_browse_video = QPushButton() - self.btn_browse_video.setMinimumHeight(BUTTON_HEIGHT) - self.btn_browse_video.clicked.connect(self.browse_video_clicked.emit) - video_layout.addWidget(self.video_edit, 1) - video_layout.addWidget(self.btn_browse_video) - - self.source_stack.addWidget(cam_page) - self.source_stack.addWidget(video_page) - - # --- action group ------------------------------------------------- - self.btn_search = QPushButton() - self.btn_search.setMinimumHeight(BUTTON_HEIGHT) - self.btn_search.clicked.connect(self.search_clicked.emit) - - self.btn_preview = QPushButton() - self.btn_preview.setMinimumHeight(BUTTON_HEIGHT) - self.btn_preview.setCheckable(True) - self.btn_preview.toggled.connect(self._on_preview_toggled) - - self.btn_run = QPushButton() - self.btn_run.setObjectName("primaryButton") - self.btn_run.setMinimumHeight(BUTTON_HEIGHT) - self.btn_run.setMinimumWidth(96) - self.btn_run.setCheckable(True) - self.btn_run.toggled.connect(self._on_run_toggled) - - self.btn_reset = QPushButton() - self.btn_reset.setMinimumHeight(BUTTON_HEIGHT) - self.btn_reset.setEnabled(False) - self.btn_reset.clicked.connect(self.reset_clicked.emit) - - # --- units group -------------------------------------------------- - self.units_label = QLabel() - self.units_label.setObjectName("fieldLabel") - self.units_combo = QComboBox() - self.units_combo.setFixedWidth(96) - self.units_combo.setMinimumHeight(CONTROL_HEIGHT) - self.units_combo.currentIndexChanged.connect(self._on_units_index_changed) - - layout.addWidget( - _group(self.source_label, self.source_combo, self.source_stack) - ) - layout.addWidget(_separator()) - layout.addWidget( - _group(self.btn_search, self.btn_preview, self.btn_run, self.btn_reset) - ) - layout.addWidget(_separator()) - layout.addWidget(_group(self.units_label, self.units_combo)) - layout.addStretch(1) - - self._populate_source_combo() - self._populate_units_combo() - self.retranslate() - self._apply_source_mode_ui() - - # ------------------------------------------------------------------ API - def source_mode(self) -> str: - data = self.source_combo.currentData() - return str(data or "camera") - - def set_source_mode(self, mode: str) -> None: - wanted = "video" if str(mode).lower().startswith("v") else "camera" - idx = self.source_combo.findData(wanted) - if idx < 0: - idx = 0 - self.source_combo.blockSignals(True) - self.source_combo.setCurrentIndex(idx) - self.source_combo.blockSignals(False) - self._apply_source_mode_ui() - - def camera_id(self) -> int: - return int(self.cam_id_spin.value()) - - def set_camera_id(self, value: int) -> None: - self.cam_id_spin.blockSignals(True) - self.cam_id_spin.setValue(int(value)) - self.cam_id_spin.blockSignals(False) - - def video_path(self) -> str: - return self.video_edit.text().strip() - - def set_video_path(self, path: str) -> None: - text = str(path or "").strip() - self.video_edit.setText(text) - self.video_edit.setToolTip(text) - - def units(self) -> str: - data = self.units_combo.currentData() - return str(data or "newton") - - def set_units(self, units: str) -> None: - wanted = "raw" if str(units).lower().startswith("r") else "newton" - idx = self.units_combo.findData(wanted) - if idx < 0: - idx = 0 - self.units_combo.blockSignals(True) - self.units_combo.setCurrentIndex(idx) - self.units_combo.blockSignals(False) - - def set_preview_checked(self, checked: bool) -> None: - self.btn_preview.blockSignals(True) - self.btn_preview.setChecked(bool(checked)) - self.btn_preview.blockSignals(False) - self.refresh_labels() - - def set_run_checked(self, checked: bool) -> None: - self.btn_run.blockSignals(True) - self.btn_run.setChecked(bool(checked)) - self.btn_run.blockSignals(False) - self.refresh_labels() - if not checked: - self.btn_reset.setEnabled(False) - - def set_reset_enabled(self, enabled: bool) -> None: - self.btn_reset.setEnabled(bool(enabled)) - - def set_camera_controls_enabled(self, enabled: bool) -> None: - self.btn_search.setEnabled(enabled and self.source_mode() == "camera") - self.cam_id_spin.setEnabled(enabled and self.source_mode() == "camera") - - def set_source_controls_enabled(self, enabled: bool) -> None: - self.source_combo.setEnabled(enabled) - self.btn_browse_video.setEnabled(enabled and self.source_mode() == "video") - self.set_camera_controls_enabled(enabled) - self.btn_preview.setEnabled(enabled and self.source_mode() == "camera") - - def set_run_enabled(self, enabled: bool) -> None: - self._run_available = bool(enabled) - self.btn_run.setEnabled(bool(enabled)) - self._apply_run_tooltip() - - def set_run_tooltip_reason(self, reason: str) -> None: - """Explain *why* Run is unavailable (shown in the button tooltip).""" - self._run_tooltip_reason = str(reason or "") - self._apply_run_tooltip() - - def active_source(self) -> str | int | None: - if self.source_mode() == "video": - path = self.video_path() - return path if path else None - return self.camera_id() - - def refresh_labels(self) -> None: - """Public refresh hook (kept separate from ``retranslate`` for reuse).""" - self._refresh_preview_label() - self._refresh_run_label() - - # ------------------------------------------------------------- internals - def _populate_source_combo(self) -> None: - self.source_combo.blockSignals(True) - self.source_combo.clear() - self.source_combo.addItem("", "camera") - self.source_combo.addItem("", "video") - self.source_combo.blockSignals(False) - - def _populate_units_combo(self) -> None: - current = self.units_combo.currentData() - self.units_combo.blockSignals(True) - self.units_combo.clear() - self.units_combo.addItem("", "newton") - self.units_combo.addItem("", "raw") - if current: - idx = self.units_combo.findData(current) - self.units_combo.setCurrentIndex(max(0, idx)) - self.units_combo.blockSignals(False) - - def _apply_source_mode_ui(self) -> None: - video = self.source_mode() == "video" - self.source_stack.setCurrentIndex(1 if video else 0) - self.btn_search.setEnabled(not video) - self.btn_preview.setEnabled(not video) - if video and self.btn_preview.isChecked(): - self.set_preview_checked(False) - - def _on_source_index_changed(self, _index: int) -> None: - self._apply_source_mode_ui() - self.source_mode_changed.emit(self.source_mode()) - - def _on_preview_toggled(self, checked: bool) -> None: - self._refresh_preview_label() - self.preview_toggled.emit(bool(checked)) - - def _on_run_toggled(self, checked: bool) -> None: - self._refresh_run_label() - if not checked: - self.btn_reset.setEnabled(False) - self.run_toggled.emit(bool(checked)) - - def _on_units_index_changed(self, _index: int) -> None: - self.units_changed.emit(self.units()) - - def _refresh_preview_label(self) -> None: - if self.btn_preview.isChecked(): - self.btn_preview.setText(t("toolbar.preview_stop")) - else: - self.btn_preview.setText(t("toolbar.preview")) - - def _refresh_run_label(self) -> None: - if self.btn_run.isChecked(): - self.btn_run.setText(t("toolbar.stop_label")) - else: - self.btn_run.setText(t("toolbar.run_label")) - self.btn_run.setObjectName( - "dangerButton" if self.btn_run.isChecked() else "primaryButton" - ) - self.btn_run.style().unpolish(self.btn_run) - self.btn_run.style().polish(self.btn_run) - self._apply_run_tooltip() - - def _apply_run_tooltip(self) -> None: - if self.btn_run.isChecked(): - self.btn_run.setToolTip(t("toolbar.stop")) - elif not self._run_available and self._run_tooltip_reason: - self.btn_run.setToolTip(self._run_tooltip_reason) - elif not self._run_available: - self.btn_run.setToolTip(t("toolbar.run_unavailable")) - else: - tip = t("toolbar.run") - if self._run_tooltip_reason: - tip = f"{tip} — {self._run_tooltip_reason}" - self.btn_run.setToolTip(tip) - - def retranslate(self) -> None: - self.source_label.setText(t("toolbar.source")) - self.source_combo.setItemText(0, t("toolbar.source_camera")) - self.source_combo.setItemText(1, t("toolbar.source_video")) - self.cam_id_label.setText(t("toolbar.cam_id")) - self.btn_browse_video.setText(t("toolbar.browse_video")) - path = self.video_path() - self.video_edit.setPlaceholderText( - Path(path).name if path else t("toolbar.video_placeholder") - ) - self.btn_search.setText(t("toolbar.search_cameras")) - self.refresh_labels() - self.btn_reset.setText(t("toolbar.reset")) - self.units_label.setText(t("toolbar.units")) - self.units_combo.setItemText(0, t("toolbar.units_newton")) - self.units_combo.setItemText(1, t("toolbar.units_raw")) diff --git a/apps/customer/src/orisys_customer/__init__.py b/apps/studio/src/orisys_studio/__init__.py similarity index 54% rename from apps/customer/src/orisys_customer/__init__.py rename to apps/studio/src/orisys_studio/__init__.py index 304e8af..c668b60 100644 --- a/apps/customer/src/orisys_customer/__init__.py +++ b/apps/studio/src/orisys_studio/__init__.py @@ -1,4 +1,4 @@ -"""Orisys customer desktop application.""" +"""Orisys Studio desktop application.""" __version__ = "0.1.0" REQUIRES_SHARED_API = "0.1" diff --git a/apps/studio/src/orisys-studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py similarity index 98% rename from apps/studio/src/orisys-studio/app_window.py rename to apps/studio/src/orisys_studio/app_window.py index a0a01f0..78bad86 100644 --- a/apps/studio/src/orisys-studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -1,4 +1,4 @@ -"""Main window shell for the customer app.""" +"""Main window shell for the studio app.""" from __future__ import annotations @@ -34,20 +34,20 @@ from orisys_app_shared.paths import logo_path, sensor_json_path from orisys_app_shared.prefs import save_preferences from orisys_app_shared.theme import build_qss, clear_theme_cache, color -from orisys_customer.ui.about_dialog import AboutDialog -from orisys_customer.ui.arrow_panel import ArrowPanel -from orisys_customer.ui.brand_bar import BrandBar -from orisys_customer.ui.cal_panel import CalPanel -from orisys_customer.ui.force_plots import ForcePlotsPanel -from orisys_customer.ui.mesh3d_panel import Mesh3dPanel -from orisys_customer.ui.plot_settings_dialog import ( +from orisys_studio.ui.about_dialog import AboutDialog +from orisys_studio.ui.arrow_panel import ArrowPanel +from orisys_studio.ui.brand_bar import BrandBar +from orisys_studio.ui.cal_panel import CalPanel +from orisys_studio.ui.force_plots import ForcePlotsPanel +from orisys_studio.ui.mesh3d_panel import Mesh3dPanel +from orisys_studio.ui.plot_settings_dialog import ( DEFAULT_PLOT_FN_MAX, DEFAULT_PLOT_SHEAR_MAX, DEFAULT_PLOT_WINDOW_SEC, PlotSettingsDialog, ) -from orisys_customer.ui.sensor_picker import SensorPickerWidget -from orisys_customer.ui.toolbar import ToolbarWidget +from orisys_studio.ui.sensor_picker import SensorPickerWidget +from orisys_studio.ui.toolbar import ToolbarWidget logger = logging.getLogger(__name__) @@ -82,7 +82,7 @@ def _reveal_in_explorer(path: Path) -> None: subprocess.run(["xdg-open", str(folder)], check=False) -class CustomerMainWindow(QMainWindow): +class StudioMainWindow(QMainWindow): def __init__(self, prefs: dict[str, Any] | None = None) -> None: super().__init__() self.prefs: dict[str, Any] = dict(prefs or {}) @@ -998,5 +998,5 @@ class CustomerMainWindow(QMainWindow): self._stop_preview(announce=False) self._stop_run(reason="user_stop") self._persist_prefs() - logger.info("Customer shell closed; preferences saved") + logger.info("Studio shell closed; preferences saved") super().closeEvent(event) diff --git a/apps/studio/src/orisys-studio/main.py b/apps/studio/src/orisys_studio/main.py similarity index 78% rename from apps/studio/src/orisys-studio/main.py rename to apps/studio/src/orisys_studio/main.py index 49ebafd..df4cef6 100644 --- a/apps/studio/src/orisys-studio/main.py +++ b/apps/studio/src/orisys_studio/main.py @@ -1,4 +1,4 @@ -"""Customer app entrypoint.""" +"""Studio app entrypoint.""" from __future__ import annotations @@ -15,14 +15,14 @@ from orisys_app_shared.paths import prefer_local_sensor_bundles from orisys_app_shared.prefs import load_preferences from orisys_app_shared.theme import build_qss -from orisys_customer import REQUIRES_SHARED_API, __version__ as app_version -from orisys_customer.app_window import CustomerMainWindow +from orisys_studio import REQUIRES_SHARED_API, __version__ as app_version +from orisys_studio.app_window import StudioMainWindow logger = logging.getLogger(__name__) def main() -> int: - init_logging("orisys-customer") + init_logging("orisys-studio") install_exception_hooks() preferred = prefer_local_sensor_bundles() if preferred is not None: @@ -42,13 +42,13 @@ def main() -> int: set_locale(language) app = QApplication(sys.argv) - app.setApplicationName("Orisys Customer") + app.setApplicationName("Orisys Studio") app.setApplicationVersion(app_version) app.setStyleSheet(build_qss(theme)) - window = CustomerMainWindow(prefs=prefs) + window = StudioMainWindow(prefs=prefs) window.show() - logger.info("Customer shell started (theme=%s, language=%s)", theme, language) + logger.info("Studio shell started (theme=%s, language=%s)", theme, language) return int(app.exec_()) diff --git a/apps/customer/src/orisys_customer/ui/__init__.py b/apps/studio/src/orisys_studio/ui/__init__.py similarity index 100% rename from apps/customer/src/orisys_customer/ui/__init__.py rename to apps/studio/src/orisys_studio/ui/__init__.py diff --git a/apps/studio/src/orisys-studio/ui/about_dialog.py b/apps/studio/src/orisys_studio/ui/about_dialog.py similarity index 96% rename from apps/studio/src/orisys-studio/ui/about_dialog.py rename to apps/studio/src/orisys_studio/ui/about_dialog.py index 3308a09..9a1869f 100644 --- a/apps/studio/src/orisys-studio/ui/about_dialog.py +++ b/apps/studio/src/orisys_studio/ui/about_dialog.py @@ -9,7 +9,7 @@ from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayo from orisys_app_shared.i18n import t from orisys_app_shared.paths import logo_topbar_path -from orisys_customer import __version__ as app_version +from orisys_studio import __version__ as app_version class AboutDialog(QDialog): diff --git a/apps/customer/src/orisys_customer/ui/arrow_panel.py b/apps/studio/src/orisys_studio/ui/arrow_panel.py similarity index 100% rename from apps/customer/src/orisys_customer/ui/arrow_panel.py rename to apps/studio/src/orisys_studio/ui/arrow_panel.py diff --git a/apps/studio/src/orisys-studio/ui/brand_bar.py b/apps/studio/src/orisys_studio/ui/brand_bar.py similarity index 98% rename from apps/studio/src/orisys-studio/ui/brand_bar.py rename to apps/studio/src/orisys_studio/ui/brand_bar.py index a43c92e..e7af339 100644 --- a/apps/studio/src/orisys-studio/ui/brand_bar.py +++ b/apps/studio/src/orisys_studio/ui/brand_bar.py @@ -1,4 +1,4 @@ -"""Brand bar shown at the top of the customer shell. +"""Brand bar shown at the top of the studio shell. A slim deep-navy rail carrying the corporate logo lockup and the product name. Logo colours are re-tinted at runtime so a single asset works on the brand diff --git a/apps/customer/src/orisys_customer/ui/cal_panel.py b/apps/studio/src/orisys_studio/ui/cal_panel.py similarity index 100% rename from apps/customer/src/orisys_customer/ui/cal_panel.py rename to apps/studio/src/orisys_studio/ui/cal_panel.py diff --git a/apps/customer/src/orisys_customer/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py similarity index 100% rename from apps/customer/src/orisys_customer/ui/force_plots.py rename to apps/studio/src/orisys_studio/ui/force_plots.py diff --git a/apps/customer/src/orisys_customer/ui/mesh3d_panel.py b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py similarity index 100% rename from apps/customer/src/orisys_customer/ui/mesh3d_panel.py rename to apps/studio/src/orisys_studio/ui/mesh3d_panel.py diff --git a/apps/customer/src/orisys_customer/ui/plot_settings_dialog.py b/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py similarity index 100% rename from apps/customer/src/orisys_customer/ui/plot_settings_dialog.py rename to apps/studio/src/orisys_studio/ui/plot_settings_dialog.py diff --git a/apps/customer/src/orisys_customer/ui/sensor_picker.py b/apps/studio/src/orisys_studio/ui/sensor_picker.py similarity index 100% rename from apps/customer/src/orisys_customer/ui/sensor_picker.py rename to apps/studio/src/orisys_studio/ui/sensor_picker.py diff --git a/apps/customer/src/orisys_customer/ui/toolbar.py b/apps/studio/src/orisys_studio/ui/toolbar.py similarity index 100% rename from apps/customer/src/orisys_customer/ui/toolbar.py rename to apps/studio/src/orisys_studio/ui/toolbar.py diff --git a/apps/studio/tests/conftest.py b/apps/studio/tests/conftest.py index d6248c8..cd0776b 100644 --- a/apps/studio/tests/conftest.py +++ b/apps/studio/tests/conftest.py @@ -1,4 +1,4 @@ -"""Shared fixtures for the customer app UI tests. +"""Shared fixtures for the studio app UI tests. The tests render real Qt widgets with the offscreen platform plugin, so they need no display and no camera hardware. A single ``QApplication`` is shared by the @@ -36,10 +36,10 @@ def qt_app(): @pytest.fixture def make_window(qt_app, tmp_path, monkeypatch): - """Build a ``CustomerMainWindow`` whose preference writes stay in tmp_path.""" + """Build a ``StudioMainWindow`` whose preference writes stay in tmp_path.""" from orisys_app_shared import prefs as prefs_pkg from orisys_app_shared import paths as paths_mod - from orisys_customer.app_window import CustomerMainWindow + from orisys_studio.app_window import StudioMainWindow prefs_file = tmp_path / "preference.json" monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file) @@ -57,7 +57,7 @@ def make_window(qt_app, tmp_path, monkeypatch): def _make(**overrides): data = dict(default_prefs) data.update(overrides) - window = CustomerMainWindow(prefs=data) + window = StudioMainWindow(prefs=data) created.append(window) return window diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index 6117826..0afcab2 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""Dependency-free smoke run for the customer UI. +"""Dependency-free smoke run for the studio UI. -``pytest`` is the canonical runner (``apps/customer/tests``), but this script +``pytest`` is the canonical runner (``apps/studio/tests``), but this script needs nothing beyond the app's own runtime dependencies, so it can be used in minimal environments: @@ -100,7 +100,7 @@ GRAPHIC_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = ( VIZ_KEYS = ("plot_fn", "plot_fx", "plot_fy") -# Deliberate, documented exemptions (see apps/customer/docs/ui-palette.md): +# Deliberate, documented exemptions (see apps/studio/docs/ui-palette.md): # decorative 1px container outlines, and disabled states which WCAG 1.4.3 # explicitly exempts. DECORATIVE_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = ( @@ -261,7 +261,7 @@ def main() -> int: from orisys_app_shared.force.calibration_io import CalSample from orisys_app_shared.i18n import set_locale, t from orisys_app_shared.theme import build_qss, load_colors - from orisys_customer.app_window import CustomerMainWindow + from orisys_studio.app_window import StudioMainWindow app = QApplication.instance() or QApplication(sys.argv) set_locale("zh_CN") @@ -297,7 +297,7 @@ def main() -> int: section(f"window {theme}/{sensor}") scene_count += 1 app.setStyleSheet(build_qss(theme)) - window = CustomerMainWindow( + window = StudioMainWindow( prefs={ "language": "zh_CN", "theme": theme, @@ -401,7 +401,7 @@ def main() -> int: section("small window") app.setStyleSheet(build_qss("dark")) - window = CustomerMainWindow( + window = StudioMainWindow( prefs={ "language": "en_US", "theme": "dark", diff --git a/apps/studio/tools/ui_snapshot.py b/apps/studio/tools/ui_snapshot.py index 1ba2f89..bb9ed6a 100644 --- a/apps/studio/tools/ui_snapshot.py +++ b/apps/studio/tools/ui_snapshot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Render the customer UI offscreen to PNG files for visual review. +"""Render the studio UI offscreen to PNG files for visual review. The tool never opens a camera and never starts acquisition: it builds the real main window, optionally injects deterministic demo data, then grabs the window. @@ -63,7 +63,7 @@ def _demo_data(window) -> None: ) from orisys_app_shared.theme import color - from orisys_customer.app_window import _bgr_to_qimage + from orisys_studio.app_window import _bgr_to_qimage size = 320 grid_y, grid_x = np.mgrid[0:size, 0:size].astype(np.float32) @@ -110,7 +110,7 @@ def main() -> int: from orisys_app_shared.i18n import set_locale from orisys_app_shared.theme import build_qss - from orisys_customer.app_window import CustomerMainWindow + from orisys_studio.app_window import StudioMainWindow app = QApplication(sys.argv) set_locale(args.lang) @@ -125,7 +125,7 @@ def main() -> int: "show_fps": True, "image_view_mode": args.view, } - window = CustomerMainWindow(prefs=prefs) + window = StudioMainWindow(prefs=prefs) window.resize(width, height) window.show() app.processEvents() diff --git a/build/README.md b/build/README.md index 7a1dcd6..07bc54f 100644 --- a/build/README.md +++ b/build/README.md @@ -1,19 +1,19 @@ # Build -Windows packaging for the customer desktop app. +Windows packaging for the studio desktop app. ## Layout ```text build/ - customer/ + studio/ entry.py # frozen entry (no source chdir) - orisys-customer.spec # PyInstaller onedir spec + orisys-studio.spec # PyInstaller onedir spec build.ps1 # create/reuse .venv-build and build _pyinstaller/ # PyInstaller workpath (gitignored) dist/ - OrisysCustomer/ - OrisysCustomer.exe + OrisysStudio/ + OrisysStudio.exe config/ # lean locales + sensors (no finger visualization/) _internal/ # Python, PyQt5, opencv-headless, orisys, … ``` @@ -26,7 +26,7 @@ dist/ ## Build -From `build\customer` (recommended): +From `build\studio` (recommended): ```bat build.bat @@ -37,7 +37,7 @@ build.bat -SkipInstall Or from repo root: ```powershell -powershell -ExecutionPolicy Bypass -File .\build\customer\build.ps1 +powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1 ``` `build.bat` always resolves `build.ps1` next to itself (cwd-independent) and forwards flags. @@ -49,7 +49,7 @@ Flags: | `-Clean` | Pass `--clean` to PyInstaller (use after dep/spec changes) | | `-SkipInstall` | Reuse packages already in `.venv-build` (faster iteration) | -Output: `dist\OrisysCustomer\OrisysCustomer.exe` +Output: `dist\OrisysStudio\OrisysStudio.exe` ## Dependency notes @@ -59,22 +59,22 @@ Output: `dist\OrisysCustomer\OrisysCustomer.exe` ## Size / startup -- **onedir** (not onefile): faster cold start; ship the whole `OrisysCustomer` folder. +- **onedir** (not onefile): faster cold start; ship the whole `OrisysStudio` folder. - Finger `assets/visualization/` is stripped from the shipped `config/` (~11 MB saved). -- Customer shell defers OpenCV / acquisition imports until Preview or Run. +- Studio shell defers OpenCV / acquisition imports until Preview or Run. Expect hundreds of MB total (Qt + numpy/scipy/numba + OpenCV). Without Open3D this stays smaller than a full `orisys[gui]` bundle. ## Verification -1. Launch `OrisysCustomer.exe` on the build machine (and ideally a clean PC without Python). +1. Launch `OrisysStudio.exe` on the build machine (and ideally a clean PC without Python). 2. Theme / locale / prefs work; `preference.json` and `outputs\` appear next to the EXE. 3. Camera preview + box/mini/finger Run (video) with image modes and force plots. -4. Confirm no `open3d*` files under `dist\OrisysCustomer`. +4. Confirm no `open3d*` files under `dist\OrisysStudio`. ## Editable run (unchanged) ```powershell -$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\customer\src" -python D:\repo\orisys-app\apps\customer\run.py +$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\studio\src" +python D:\repo\orisys-app\apps\studio\run.py ``` diff --git a/build/customer/entry.py b/build/customer/entry.py deleted file mode 100644 index 21326ee..0000000 --- a/build/customer/entry.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Frozen entry for Orisys Customer (no source-tree chdir).""" - -from __future__ import annotations - -from orisys_customer.main import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/build/customer/build.bat b/build/studio/build.bat similarity index 83% rename from build/customer/build.bat rename to build/studio/build.bat index 65710d0..168bb24 100644 --- a/build/customer/build.bat +++ b/build/studio/build.bat @@ -1,6 +1,6 @@ @echo off setlocal EnableExtensions -REM Build Orisys Customer EXE. Works from any cwd; forwards args to build.ps1 +REM Build Orisys Studio EXE. Works from any cwd; forwards args to build.ps1 REM Examples: REM build.bat REM build.bat -Clean diff --git a/build/customer/build.ps1 b/build/studio/build.ps1 similarity index 90% rename from build/customer/build.ps1 rename to build/studio/build.ps1 index 8d8c667..c48fb02 100644 --- a/build/customer/build.ps1 +++ b/build/studio/build.ps1 @@ -1,7 +1,7 @@ #Requires -Version 5.1 <# .SYNOPSIS - Build Orisys Customer Windows onedir EXE with PyInstaller. + Build Orisys Studio Windows onedir EXE with PyInstaller. .PARAMETER Clean Pass --clean to PyInstaller (slower; use when deps/spec change). @@ -19,7 +19,7 @@ $ErrorActionPreference = "Stop" $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path $VenvDir = Join-Path $RepoRoot ".venv-build" $Python = Join-Path $VenvDir "Scripts\python.exe" -$Spec = Join-Path $PSScriptRoot "orisys-customer.spec" +$Spec = Join-Path $PSScriptRoot "orisys-studio.spec" $WorkPath = Join-Path $RepoRoot "build\_pyinstaller" $DistPath = Join-Path $RepoRoot "dist" @@ -31,11 +31,11 @@ if (-not (Test-Path $Python)) { } if (-not $SkipInstall) { - Write-Host "Installing build dependencies (editable shared + customer + orisys + pyinstaller)…" + Write-Host "Installing build dependencies (editable shared + studio + orisys + pyinstaller)…" & $Python -m pip install --upgrade pip & $Python -m pip install "pyinstaller>=6.0" & $Python -m pip install -e (Join-Path $RepoRoot "shared") - & $Python -m pip install -e (Join-Path $RepoRoot "apps\customer") + & $Python -m pip install -e (Join-Path $RepoRoot "apps\studio") # orisys may already be installed; ensure present without [gui]/open3d & $Python -m pip install "orisys>=0.5" @@ -73,12 +73,12 @@ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -$OutDir = Join-Path $DistPath "OrisysCustomer" +$OutDir = Join-Path $DistPath "OrisysStudio" Write-Host "" Write-Host "Build complete: $OutDir" # Lean config beside EXE (exclude finger visualization assets) -$ConfigSrc = Join-Path $RepoRoot "apps\customer\config" +$ConfigSrc = Join-Path $RepoRoot "apps\studio\config" $ConfigDst = Join-Path $OutDir "config" if (Test-Path $ConfigSrc) { Write-Host "Copying lean config to $ConfigDst …" @@ -96,7 +96,7 @@ if (Test-Path $ConfigSrc) { } } -Write-Host "Launch: $(Join-Path $OutDir 'OrisysCustomer.exe')" +Write-Host "Launch: $(Join-Path $OutDir 'OrisysStudio.exe')" # Size summary if (Test-Path $OutDir) { diff --git a/build/studio/entry.py b/build/studio/entry.py new file mode 100644 index 0000000..7df182d --- /dev/null +++ b/build/studio/entry.py @@ -0,0 +1,8 @@ +"""Frozen entry for Orisys Studio (no source-tree chdir).""" + +from __future__ import annotations + +from orisys_studio.main import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/customer/orisys-customer.spec b/build/studio/orisys-studio.spec similarity index 94% rename from build/customer/orisys-customer.spec rename to build/studio/orisys-studio.spec index 2738eb2..49f5ec9 100644 --- a/build/customer/orisys-customer.spec +++ b/build/studio/orisys-studio.spec @@ -1,5 +1,5 @@ # -*- mode: python ; coding: utf-8 -*- -"""PyInstaller onedir spec for Orisys Customer (Windows).""" +"""PyInstaller onedir spec for Orisys Studio (Windows).""" from __future__ import annotations @@ -57,7 +57,7 @@ icon = str(icon_path) if icon_path.is_file() else None a = Analysis( [str(ENTRY)], pathex=[ - str(REPO_ROOT / "apps" / "customer" / "src"), + str(REPO_ROOT / "apps" / "studio" / "src"), str(REPO_ROOT / "shared" / "src"), ], binaries=binaries, @@ -105,7 +105,7 @@ exe = EXE( a.scripts, [], exclude_binaries=True, - name="OrisysCustomer", + name="OrisysStudio", debug=False, bootloader_ignore_signals=False, strip=False, @@ -127,5 +127,5 @@ coll = COLLECT( strip=False, upx=False, upx_exclude=[], - name="OrisysCustomer", + name="OrisysStudio", ) diff --git a/pyproject.toml b/pyproject.toml index 2711c0f..d11dbdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "orisys-app" version = "0.1.0" -description = "Orisys desktop applications (customer + future internal)" +description = "Orisys desktop applications (studio + future internal)" requires-python = ">=3.10" readme = "README.md" authors = [{ name = "Orisys-Tech" }] @@ -13,7 +13,7 @@ authors = [{ name = "Orisys-Tech" }] [project.optional-dependencies] dev = [ "orisys-app-shared @ file:./shared", - "orisys-customer @ file:./apps/customer", + "orisys-studio @ file:./apps/studio", "pytest>=7.4", ] @@ -21,6 +21,6 @@ dev = [ packages = [] [tool.pytest.ini_options] -testpaths = ["shared/tests", "apps/customer/tests"] -pythonpath = ["shared/src", "apps/customer/src"] +testpaths = ["shared/tests", "apps/studio/tests"] +pythonpath = ["shared/src", "apps/studio/src"] addopts = "-q" diff --git a/shared/src/orisys_app_shared/paths.py b/shared/src/orisys_app_shared/paths.py index 7a71833..637e04c 100644 --- a/shared/src/orisys_app_shared/paths.py +++ b/shared/src/orisys_app_shared/paths.py @@ -10,25 +10,25 @@ def is_frozen() -> bool: return bool(getattr(sys, "frozen", False)) -def _customer_root_from_shared() -> Path: - """Repo layout: shared/src/orisys_app_shared -> apps/customer.""" +def _studio_root_from_shared() -> Path: + """Repo layout: shared/src/orisys_app_shared -> apps/studio.""" here = Path(__file__).resolve() # .../orisys-app/shared/src/orisys_app_shared/paths.py repo_root = here.parents[3] - return repo_root / "apps" / "customer" + return repo_root / "apps" / "studio" def app_root() -> Path: - """Return the customer app root (editable) or EXE directory (frozen).""" + """Return the studio app root (editable) or EXE directory (frozen).""" if is_frozen(): return Path(sys.executable).resolve().parent - # Prefer cwd override when launched from apps/customer + # Prefer cwd override when launched from apps/studio cwd = Path.cwd().resolve() if (cwd / "config").is_dir() and (cwd / "run.py").is_file(): return cwd - if (cwd / "apps" / "customer" / "config").is_dir(): - return cwd / "apps" / "customer" - return _customer_root_from_shared() + if (cwd / "apps" / "studio" / "config").is_dir(): + return cwd / "apps" / "studio" + return _studio_root_from_shared() def config_dir() -> Path: