diff --git a/README.md b/README.md index ec87487..bf01a06 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,35 @@ Desktop applications that consume the Orisys SDK. -| App | Status | -|-----|--------| -| `apps/studio` | Scaffold shell (theme / locale / prefs / logging) | -| `apps/internal` | Stub for future calibration & automated testing | +| App | Status | Qt binding | +|-----|--------|-----------| +| `apps/studio` | Scaffold shell (theme / locale / prefs / logging) | PyQt5 | +| `apps/autocal` | Internal 5-axis calibration platform control | **PySide6** | +| `apps/internal` | Stub, superseded by `apps/autocal` | — | + +The two shipped apps use different Qt bindings on purpose. PyQt5's free option +is **GPLv3-or-commercial**; PySide6 is **LGPLv3**, which is why the internal +closed-source tool uses PySide6 and why `shared/` must import neither binding. +Migrating `apps/studio` to PySide6 is a separate, build-validated change. ## Layout -- `shared/` — common paths, prefs, theme, i18n, logging +- `shared/` — common paths, prefs, theme, i18n, logging, and the ZMotion client +- `shared/resources/zmotion/` — vendored ZMotion runtime DLLs (internal use only) - `apps/studio/config/` — locales, default prefs, editable `sensors/` copies - `apps/studio/outputs/` — generated logs and calibration exports (gitignored) +- `apps/autocal/` — internal platform control; see `apps/autocal/README.md` - `build/` — Windows PyInstaller packaging (`build/studio/build.ps1`) +### Selecting which app's paths resolve + +`orisys_app_shared.paths` reads `ORISYS_APP_ID` (an `apps/` directory name) to +decide which product's `config/`, `preference.json` and `outputs/` a process +owns. Each app's launcher sets it before importing the shared package; when it +is unset the studio is assumed, which keeps existing callers working unchanged. +A named app whose `config/` is missing raises rather than silently resolving +another product's tree. + ## Run (studio shell) ```powershell diff --git a/apps/autocal/README.md b/apps/autocal/README.md new file mode 100644 index 0000000..db27691 --- /dev/null +++ b/apps/autocal/README.md @@ -0,0 +1,142 @@ +# Orisys AutoCal + +Internal control application for the **5-axis calibration platform**, driving a +ZMotion ZMC bus controller (EtherCAT or RTEX) from the PC. + +Replaces the vendor example program it was ported from: + + D:\orisys_ws\zmotion\Python64\例程9-总线控制è¿�动 + +> **Internal use only.** AutoCal is not part of the customer installer. See +> "Do not ship this app" below. + +## What it does + +- **Connect** over Ethernet (with an IP scan), serial, PCI, local/simulated, or + a built-in **demo** transport that needs no hardware and no DLL. +- **Initialise the bus** by downloading `ECAT.bas` or `RTEX.bas` to the + controller, then follow the controller's own state machine to completion — or + to a timeout, with the vendor's diagnostic hint. +- **Read axis state** for all five drives at once: enable, axis type, pulse + equivalent, speed, acceleration, commanded and measured position (in user + units *and* millimetres), axis status word, motion and homing state. +- **Move**: enable/disable, jog, absolute and relative moves, stop, stop-all, + driver homing, clear a drive alarm. Every command is guarded — see below. +- **Exchange raw bus data**: EtherCAT object-dictionary (SDO) reads and writes, + and RTEX parameters. Each family is enabled only when the controller reports + that bus type. + +## Run it + +PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no +licence purchase. (`PyQt5`, which the studio app uses, is GPLv3-or-commercial.) + +```powershell +# with the environment that already has PySide6 (Anaconda py310, for example) +$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\autocal\src" +python D:\orisys_ws\orisys-app\apps\autocal\run.py +``` + +Or with no controller attached at all: + +```powershell +python .\apps\autocal\run.py --demo +``` + +`run.py` sets `ORISYS_APP_ID=autocal` before importing anything from +`orisys_app_shared`, so this app's `config/`, `preference.json` and `outputs/` +resolve to `apps/autocal/` rather than the studio's. + +### Controller library + +The ZMotion runtime is vendored at `shared/resources/zmotion/x64/` (see the +README beside it for provenance). Point `ORISYS_ZMOTION_DLL_DIR` at another +directory to use a different build — on a motion controller, silently loading +the wrong runtime version is the kind of fault that looks like a working machine +until it moves, so the override is authoritative: if it does not contain +`zauxdll.dll`, the app refuses to connect instead of quietly falling back. + +## Safety model + +The guards live in `motion/service.py`, not in the UI, so they hold whatever +calls them: + +- **Motion is refused** while the bus is not initialised, while the axis is + unknown, or on a non-finite target — with a reason, never a silent no-op. +- **Homing needs a configured mode.** The vendor example pre-fills `17`, which + is one specific drive's mode; AutoCal starts at `0` and refuses, so the + operator chooses for the drive in front of them instead of inheriting a number + from another machine. +- **Homing is refused on RTEX**, as the vendor example itself does. +- **Disconnect stops the axes first** (the vendor program closes the handle and + leaves the platform coasting). +- **SDO/RTEX are gated by the reported bus type**, so a write can never be aimed + at the wrong family. +- **A failed read is marked, not zeroed.** `ZmotionValue.value_or_none` returns + `None` on failure; nothing silently treats a failed read's zero as a position. + +The displayed millimetre value is *derived* from the pulse equivalent +(bench-measured: `units=10` → 1000 user units = 2 mm). It is a display value, +not a calibrated length, and the UI says so. + +## Layout + +```text +apps/autocal/ + run.py # source-tree launcher + config/ + preferences.default.json # shipped defaults + locales/{zh_CN,en_US}.json + bas/{ECAT,RTEX}.bas # copies: the vendor needs a non-Chinese path + src/orisys_autocal/ + main.py # logging, locale, theme, window + preferences.py # AutoCal's own preference schema + motion/ # Qt-free except poller.py (the Qt worker bridge) + models.py errors.py units.py service.py transport.py poller.py + ui/ # window.py + one module per panel + tools/ # panel_check.py, panel_snapshot.py + tests/ +``` + +`motion/poller.py` is the only place a controller call can originate. It owns +the DLL handle on its own thread, so the vendor library's blocking calls (a ROM +write, a bus scan) never freeze the window and the ctypes handle is never shared +between threads. The UI talks to it through signals only. + +## Verify + +Both work with no display, no controller and no DLL: + +```powershell +python .\apps\autocal\tools\panel_check.py # 120 layout/theme/locale/gating checks +python .\apps\autocal\tools\panel_snapshot.py --demo --theme dark --out shot.png +python .\apps\autocal\tools\panel_snapshot.py --compare --out tabs.png # one image per tab +python .\apps\autocal\tools\layout_mockup.py --out layout.png # proposed layout, drawn +python -m pytest apps\autocal\tests # offscreen suite +``` + +`layout_mockup.py` is a **wireframe, not the application**: it paints the +proposed single-page layout with the shared theme tokens and imports nothing +from the app, so the layout can be reviewed and marked up without building the +panels. It draws the full page plus previews of the other workspace tabs in one +canvas. + +`panel_check.py` builds the real window in both themes at 1280×720, 1600×900 and +1920×1080, asserts that no widget escapes its parent, that motion is +unreachable before the bus is initialised, that the SDO/RTEX panels follow the +reported bus family, and then runs a full simulated session from `ECAT.bas`. + +The suite is written for **PySide6**. Where PySide6 is missing, the test modules +are skipped rather than failed: a broken environment should not look like a +broken application. `tests/test_binding_purity.py` enforces the binding policy +statically — no module may import PyQt5/PySide2/PyQt6, no module may use an +unscoped Qt enumerator (removed in PySide6), `shared/` may import no binding at +all, and the controller interface must stay importable headless. + +## Do not ship this app + +AutoCal is an internal tool. It is deliberately **not** packaged by +`build/studio`, whose PyInstaller spec must not learn about the ZMotion runtime +DLLs, and it is not in the customer installer. The ZMotion SDK is vendor-supplied +hardware support software with its own terms; do not redistribute those DLLs +with a customer product. diff --git a/apps/autocal/config/bas/ECAT.bas b/apps/autocal/config/bas/ECAT.bas new file mode 100644 index 0000000..da5fe16 --- /dev/null +++ b/apps/autocal/config/bas/ECAT.bas @@ -0,0 +1,103 @@ + +'*******************************************************ECAT×ÜÏß³õʼ»¯ +''''''''' + +global CONST BUS_TYPE = 0 '×ÜÏßÀàÐÍ¡£ÓÃÓÚÉÏλ»úÇø·Öµ±Ç°Ä£Ê½ +global CONST MAX_AXISNUM = 32 '×î´óÖáÊý +global CONST Bus_Slot = 0 '²ÛλºÅ0 +global CONST Bus_AxisStart = 0 '×ÜÏßÖáÆðʼÖáºÅ + +global Bus_InitStatus '×ÜÏß³õʼ»¯Íê³É״̬ +Bus_InitStatus = -1 +global Bus_TotalAxisnum '¼ì²éɨÃèµÄ×ÜÖáÊý + +delay(3000) 'ÑÓʱ3SµÈ´ýÇý¶¯Æ÷Éϵç +'**********************³õʼ»¯ECAT×ÜÏß +Ecat_Init() + +end + +global sub Ecat_Init() + for i=0 to MAX_AXISNUM - 1 '³õʼ»¯»¹Ô­ÖáÀàÐÍ + AXIS_ENABLE(i) = 0 + atype(i)=0 + next + + Bus_InitStatus = -1 + Bus_TotalAxisnum = 0 + SLOT_STOP(Bus_Slot) + delay(200) + slot_scan(Bus_Slot) '¿ªÊ¼É¨Ãè + if return then + + ?"×ÜÏßɨÃè³É¹¦","Á¬½ÓÉ豸Êý£º"NODE_COUNT(Bus_Slot) + ? + ?"¿ªÊ¼Ó³ÉäÖáºÅ" + for i=0 to NODE_COUNT(Bus_Slot)-1 '±éÀú×ÜÏßÏÂËùÓдÓÕ¾½Úµã + + if NODE_AXIS_COUNT(Bus_Slot,i) <>0 then 'Åжϵ±Ç°½ÚµãÊÇ·ñÓеç»ú + for j=0 to NODE_AXIS_COUNT(Bus_Slot,i)-1 + AXIS_ADDRESS(Bus_AxisStart+i)=Bus_TotalAxisnum+1 'Ó³ÉäÖáºÅ + ATYPE(Bus_AxisStart+i)=65 'ÉèÖÿØÖÆÄ£Ê½ 65-λÖà 66-ËÙ¶È 67-ת¾Ø + DRIVE_PROFILE(Bus_AxisStart+i)= 4 'ÉèÖÃPROFILE¹¦ÄÜ + disable_group(Bus_AxisStart+i) 'ÿÖáµ¥¶À·Ö×é + + DRIVE_IO(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16 'Ó³ÉäÇý¶¯Æ÷ÉϵÄIO״̬ + REV_IN(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16 + FWD_IN(Bus_AxisStart+i) = 129 + (Bus_AxisStart+i)*16 + DATUM_IN(Bus_AxisStart+i) = 130 + (Bus_AxisStart+i)*16 + INVERT_IN(128 + (Bus_AxisStart+i)*16,ON) + INVERT_IN(129 + (Bus_AxisStart+i)*16,ON) + INVERT_IN(130 + (Bus_AxisStart+i)*16,ON) + + Bus_TotalAxisnum=Bus_TotalAxisnum+1 '×ÜÖáÊý+1 + next + endif + + next + + ?"ÖáºÅÓ³ÉäÍê³É","Á¬½Ó×ÜÖáÊý£º"Bus_TotalAxisnum + wa 2000 + + SLOT_START(Bus_Slot) 'Æô¶¯×ÜÏß + if return then + ?"×ÜÏß¿ªÆô³É¹¦" + + ?"¿ªÊ¼Çå³ýÇý¶¯Æ÷´íÎó(¸ù¾ÝÇý¶¯Æ÷Êý¾Ý×ÖµäÉèÖÃ)" + for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1 + + DRIVE_CONTROLWORD(i)=128 '¸ù¾ÝÇý¶¯Æ÷Êý¾Ý×Öµä + wa 100 + DRIVE_CONTROLWORD(i)=6 + wa 100 + DRIVE_CONTROLWORD(i)=15 + wa 100 + next + + ?"Çý¶¯Æ÷´íÎóÇå³ýÍê³É" + wa 100 + + ?"Çå³ý¿ØÖÆÆ÷´íÎó" + datum(0) + DRIVE_CLEAR(0) + ?"¿ØÖÆÆ÷´íÎóÇå³ýÍê³É" + wa 100 + + ?"ÖáʹÄÜ×¼±¸" + for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1 + base(i) + AXIS_ENABLE=1 + next + wdog=1 'ʹÄÜ×Ü¿ª¹Ø + Bus_InitStatus = 1 + ?"ÖáʹÄÜÍê³É" + else + ?"×ÜÏß¿ªÆôʧ°Ü" + Bus_InitStatus = 0 + endif + + else + ?"×ÜÏßɨÃèʧ°Ü" + Bus_InitStatus = 0 + endif +end sub \ No newline at end of file diff --git a/apps/autocal/config/bas/RTEX.bas b/apps/autocal/config/bas/RTEX.bas new file mode 100644 index 0000000..1a9a481 --- /dev/null +++ b/apps/autocal/config/bas/RTEX.bas @@ -0,0 +1,103 @@ + +'*******************************************************RTEX×ÜÏß³õʼ»¯ +''''''''' +global CONST BUS_TYPE = 1 '×ÜÏßÀàÐÍ¡£ÓÃÓÚÉÏλ»úÇø·Öµ±Ç°Ä£Ê½ +global CONST MAX_AXISNUM = 32 '×î´óÖáÊý +global CONST Bus_Slot = 0 '²ÛλºÅ0 +global CONST Bus_AxisStart = 0 '×ÜÏßÖáÆðʼÖáºÅ + +global Bus_InitStatus '×ÜÏß³õʼ»¯Íê³É״̬ +Bus_InitStatus = -1 +global Bus_TotalAxisnum '¼ì²éɨÃèµÄ×ÜÖáÊý + +delay(3000) 'ÑÓʱ3SµÈ´ýÇý¶¯Æ÷Éϵç +'**********************³õʼ»¯RTEX×ÜÏß +Rtex_Init() + +end + +global sub Rtex_Init() + + for i=0 to MAX_AXISNUM - 1 '³õʼ»¯»¹Ô­ÖáÀàÐÍ + AXIS_ENABLE(i) = 0 + atype(i)=0 + next + + Bus_InitStatus = -1 + Bus_TotalAxisnum = 0 + SLOT_STOP(Bus_Slot) + delay(200) + slot_scan(Bus_Slot) '¿ªÊ¼É¨Ãè + if return then + + ?"×ÜÏßɨÃè³É¹¦","Á¬½ÓÉ豸Êý£º"NODE_COUNT(Bus_Slot) + ? + ?"¿ªÊ¼Ó³ÉäÖáºÅ" + for i=0 to NODE_COUNT(Bus_Slot)-1 '±éÀú×ÜÏßÏÂËùÓдÓÕ¾½Úµã + + if NODE_AXIS_COUNT(Bus_Slot,i) <>0 then 'Åжϵ±Ç°½ÚµãÊÇ·ñÓеç»ú + for j=0 to NODE_AXIS_COUNT(Bus_Slot,i)-1 + AXIS_ADDRESS(Bus_AxisStart+i)=Bus_TotalAxisnum+1 'Ó³ÉäÖáºÅ + ATYPE(Bus_AxisStart+i)=50 'ÉèÖÿØÖÆÄ£Ê½ 50-λÖà 51-ËÙ¶È 52-ת¾Ø + DRIVE_PROFILE(Bus_AxisStart+i)= 4 'ÉèÖÃPROFILE¹¦ÄÜ + disable_group(Bus_AxisStart+i) 'ÿÖáµ¥¶À·Ö×é + + DRIVE_IO(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16 'Ó³ÉäÇý¶¯Æ÷ÉϵÄIO״̬ + REV_IN(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16 + FWD_IN(Bus_AxisStart+i) = 129 + (Bus_AxisStart+i)*16 + DATUM_IN(Bus_AxisStart+i) = 130 + (Bus_AxisStart+i)*16 + INVERT_IN(128 + (Bus_AxisStart+i)*16,ON) + INVERT_IN(129 + (Bus_AxisStart+i)*16,ON) + INVERT_IN(130 + (Bus_AxisStart+i)*16,ON) + + Bus_TotalAxisnum=Bus_TotalAxisnum+1 '×ÜÖáÊý+1 + next + endif + + next + + ?"ÖáºÅÓ³ÉäÍê³É","Á¬½Ó×ÜÖáÊý£º"Bus_TotalAxisnum + wa 2000 + + SLOT_START(Bus_Slot) 'Æô¶¯×ÜÏß + if return then + ?"×ÜÏß¿ªÆô³É¹¦" + + ?"¿ªÊ¼Çå³ýÇý¶¯Æ÷´íÎó(¸ù¾ÝÇý¶¯Æ÷Êý¾Ý×ÖµäÉèÖÃ)" + for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1 + + DRIVE_CONTROLWORD(i)=128 '¸ù¾ÝÇý¶¯Æ÷Êý¾Ý×Öµä + wa 100 + DRIVE_CONTROLWORD(i)=6 + wa 100 + DRIVE_CONTROLWORD(i)=15 + wa 100 + next + + ?"Çý¶¯Æ÷´íÎóÇå³ýÍê³É" + wa 100 + + ?"Çå³ý¿ØÖÆÆ÷´íÎó" + datum(0) + DRIVE_CLEAR(0) + ?"¿ØÖÆÆ÷´íÎóÇå³ýÍê³É" + wa 100 + + ?"ÖáʹÄÜ×¼±¸" + for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1 + base(i) + AXIS_ENABLE=1 + next + wdog=1 'ʹÄÜ×Ü¿ª¹Ø + Bus_InitStatus = 1 + ?"ÖáʹÄÜÍê³É" + else + ?"×ÜÏß¿ªÆôʧ°Ü" + Bus_InitStatus = 0 + endif + + else + ?"×ÜÏßɨÃèʧ°Ü" + Bus_InitStatus = 0 + endif +end sub \ No newline at end of file diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json new file mode 100644 index 0000000..7aae0d5 --- /dev/null +++ b/apps/autocal/config/locales/en_US.json @@ -0,0 +1,254 @@ +{ + "app": { + "title": "Orisys AutoCal Platform Control", + "about_title": "About Orisys AutoCal", + "about_body": "Orisys AutoCal — internal controller for the 5-axis calibration platform\nVersion: {app_version}\nShared library: {shared_version} (API {api_version})\n\nQt binding: {binding}\nController library: {dll_dir}\nPreferences: {prefs_path}\n\nInternal use only.", + "internal_notice": "Internal tool: not shipped in the customer installer." + }, + "brand": { + "name": "ORISYS", + "detail": "Orisys Technology" + }, + "menu": { + "file": "File", + "open_bas": "Download BAS init program…", + "reveal_config": "Show config in Explorer…", + "quit": "Quit", + "view": "View", + "theme": "Theme", + "theme_dark": "Dark", + "theme_light": "Light", + "language": "Language", + "lang_zh": "简体中文", + "lang_en": "English", + "show_diagnostics": "Diagnostics", + "tabs": "Workspace", + "outputs": "Outputs folder", + "help": "Help", + "about": "About AutoCal" + }, + "connection": { + "title": "Controller connection", + "mode": "Connection", + "mode_eth": "Ethernet", + "mode_com": "Serial", + "mode_pci": "PCI card", + "mode_local": "Local / simulated", + "mode_demo": "Demo (no hardware)", + "ip": "IP", + "scan": "Scan IP", + "com": "COM port", + "card": "Card", + "local_string": "Connect string", + "open": "Connect", + "close": "Disconnect", + "status": "Status", + "state_open": "Connected", + "state_closed": "Not connected", + "state_connecting": "Connecting…", + "source": "Library", + "no_more": "The 『{mode}ã€� connection is not available yet." + }, + "bus": { + "title": "Bus initialisation", + "bas_path": "BAS file", + "choose": "Choose BAS…", + "download": "Download and initialise", + "reinit": "Re-initialise", + "type": "Bus type", + "type_ecat": "EtherCAT", + "type_rtex": "RTEX", + "type_unknown": "Unknown", + "state": "Init state", + "state_pending": "Not started", + "state_running": "Initialising…", + "state_ok": "Initialised", + "state_failed": "Initialisation failed", + "state_timeout": "Initialisation timed out", + "nodes": "Nodes", + "axes": "Axes", + "downloaded": "File downloaded; the controller will scan the bus after its delay", + "dialog_title": "Choose a BAS file" + }, + "axes": { + "title": "Axis motion", + "axis": "Axis", + "enable": "Enable", + "enable_on": "Enabled", + "enable_off": "Disabled", + "atype": "Type", + "units": "Units", + "speed": "Speed", + "accel": "Accel", + "dpos": "DPOS", + "mpos": "MPOS", + "dpos_mm": "DPOS (mm)", + "mpos_mm": "MPOS (mm)", + "status": "Status", + "motion": "Motion", + "motion_word": "Motion", + "idle": "Stopped", + "moving": "Moving", + "position_pair": "{value} ({mm} mm)", + "enable_all": "Enable all", + "disable_all": "Disable all", + "clear_alarm_all": "Clear all alarms", + "detail": "Axis {axis}: type {atype} · accel {accel} · feedback {mpos} ({mpos_mm} mm) · status {status} · {home}", + "detail_none": "Axis {axis}: no data yet (not connected or not initialised)", + "home_status": "Homing", + "home_ok": "Homed", + "home_pending": "Not homed", + "home_running": "Homing…", + "jog_fwd": "Fwd", + "jog_rev": "Rev", + "stop_all": "Stop all", + "stop": "Stop", + "target": "Target", + "move_abs": "Move abs", + "move_rel": "Move rel", + "home_mode": "Home mode", + "home_high": "Home speed", + "home_low": "Home creep", + "home_offset": "Home offset", + "clear_alarm": "Clear alarm", + "apply": "Apply", + "home": "Home", + "controls": "Actions", + "mm_derived_tip": "Millimetres are derived from the pulse equivalent: a display value, not a calibrated length.", + "enabled_count": "{on}/{total} axes enabled" + }, + "busdata": { + "title": "Bus data exchange", + "group": "Bus data (SDO / RTEX)", + "ecat": "EtherCAT (SDO)", + "rtex": "RTEX", + "node": "Node", + "index": "Index", + "subindex": "Subindex", + "type": "Type", + "value": "Value", + "read": "Read", + "write": "Write", + "axis": "Axis", + "parameter": "Parameter", + "last_result": "Last: {text}", + "not_available": "The current bus does not support this", + "read_ok": "Read: {value}", + "write_ok": "Written" + }, + "status": { + "motion": "Motion", + "ready": "Ready", + "poll_ok": "State refresh healthy", + "poll_paused": "State refresh paused", + "poll_stalled": "State refresh stalled", + "last_error": "Last error", + "none": "None", + "refused": "Refused", + "done": "Done", + "failed": "Failed", + "pause": "Pause refresh", + "resume": "Resume refresh", + "paused": "State refresh paused", + "alarm_short": "Alarm", + "axes_enabled": "{on}/{total} axes enabled", + "health_none": "Refresh —", + "health_age": "Read {ms} ms ago", + "health_paused": "Refresh paused", + "poll_every": "Refresh every {ms} ms" + }, + "run": { + "title": "Run", + "phase_idle": "Idle", + "phase_dry_run": "Dry run", + "phase_running": "Running", + "phase_paused": "Paused", + "phase_done": "Done", + "phase_failed": "Failed", + "step_of": "Point {done}/{total}", + "no_steps": "No points generated", + "elapsed_only": "Elapsed {elapsed}", + "elapsed_remaining": "Elapsed {elapsed} · about {remaining} left", + "run": "Run", + "pause": "Pause", + "abort": "Abort", + "abort_stopped": "Run aborted and all axes stopped.", + "not_implemented": "The runner belongs to phase P7 and is not implemented yet; abort is always available." + }, + "readout": { + "title": "Axis state" + }, + "workspace": { + "tab_axes": "Axis debug", + "tab_sequence": "Test sequence", + "tab_sensor": "Sensor", + "tab_surface": "Surface map", + "tab_points": "Calibration points", + "not_built": "Not built yet", + "reserved_sensor": "Sensor connection, live image and force curves will appear here: device and source selection, preview, and a compact version of the Studio curve view.", + "reserved_surface": "Multi-point calibration sensitivity will be drawn as a colour map on the sensor's 3D surface here: each point's sensitivity interpolated and coloured, with a legend and range.", + "reserved_points": "The calibration point table and capture controls will appear here: platform coordinates, reference load, sampling statistics and the resulting sensitivity." + }, + "sequence": { + "title": "Test sequence design", + "profile_title": "Point generation", + "preview_title": "Preview", + "validation_title": "Checks", + "points_title": "Calibration points", + "field_mode": "Surface mode", + "field_pitch": "Pitch (mm)", + "field_margin": "Margin (mm)", + "field_radius": "Radius (mm)", + "field_clear_height": "Clear height (mm)", + "field_approach_speed": "Approach speed (mm/s)", + "field_press_speed": "Press speed (mm/s)", + "field_press_depth": "Press depth (mm)", + "field_dwell": "Dwell (s)", + "field_settle": "Settle window (s)", + "field_repeats": "Repeats", + "field_order": "Point order", + "mode_flat": "Flat", + "mode_curved": "Curved", + "order_row": "Row-wise", + "order_serpentine": "Serpentine", + "order_distance": "By distance", + "preview_placeholder": "3D preview: sensor surface · calibration points · visiting order\n(implemented in the simulation phase)", + "preview_hint": "After generation, check here that each point is on the surface, inside travel, and confirmable by contact.", + "validation_pending": "No points generated yet.", + "check_on_surface": "On the surface", + "check_within_travel": "Within travel", + "check_contact_confirmable": "Contact confirmable", + "btn_generate": "Generate points", + "btn_simulate": "Simulate", + "btn_dry_run": "Dry run (no contact)", + "btn_save": "Save sequence…", + "btn_load": "Load sequence…", + "pending_tooltip": "This belongs to phase {phase} and is not implemented yet.", + "col_index": "#", + "col_x": "X (mm)", + "col_y": "Y (mm)", + "col_z": "Z (mm)", + "col_status": "Status", + "col_sensitivity": "Sensitivity (last)", + "col_samples": "Samples", + "col_actions": "Actions", + "not_generated": "—", + "status_pending": "Pending", + "points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked, and one wrong number ruins a run." + }, + "action": { + "ok": "Done", + "refused": "Refused", + "failed": "Failed" + }, + "diagnostics": { + "title": "Diagnostics", + "binding": "Qt binding", + "dll_dir": "Controller library", + "prefs": "Preferences", + "outputs": "Outputs", + "bas_loaded": "BAS loaded", + "yes": "Yes", + "no": "No" + } +} diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json new file mode 100644 index 0000000..2094242 --- /dev/null +++ b/apps/autocal/config/locales/zh_CN.json @@ -0,0 +1,254 @@ +{ + "app": { + "title": "Orisys AutoCal 标定å�°æŽ§åˆ¶", + "about_title": "关于 Orisys AutoCal", + "about_body": "Orisys AutoCal — 五轴标定å�°å†…部控制器\n版本: {app_version}\n共享库: {shared_version} (API {api_version})\n\nQt 绑定: {binding}\n控制器库: {dll_dir}\nå��好文件: {prefs_path}\n\n仅供内部使用。", + "internal_notice": "内部工具:ä¸�éš�客户安装包å�‘布。" + }, + "brand": { + "name": "ORISYS", + "detail": "å� åŠ¨ç§‘æŠ€" + }, + "menu": { + "file": "文件", + "open_bas": "下载 BAS åˆ�始化程åº�…", + "reveal_config": "在资æº�管ç�†å™¨ä¸­æ˜¾ç¤ºé…�置…", + "quit": "退出", + "view": "视图", + "theme": "主题", + "theme_dark": "深色", + "theme_light": "浅色", + "language": "语言", + "lang_zh": "简体中文", + "lang_en": "English", + "show_diagnostics": "诊断信æ�¯", + "tabs": "工作区", + "outputs": "输出目录", + "help": "帮助", + "about": "关于 AutoCal" + }, + "connection": { + "title": "连接控制器", + "mode": "连接方å¼�", + "mode_eth": "以太网", + "mode_com": "串å�£", + "mode_pci": "PCI å�¡", + "mode_local": "本地/仿真", + "mode_demo": "演示 (无硬件)", + "ip": "IP", + "scan": "IP 扫æ��", + "com": "串å�£å�·", + "card": "å�¡å�·", + "local_string": "连接å�‚æ•°", + "open": "连接", + "close": "断开连接", + "status": "状æ€�", + "state_open": "已连接", + "state_closed": "未连接", + "state_connecting": "连接中…", + "source": "库路径", + "no_more": "连接方å¼�『{mode}ã€�暂未æ��供。" + }, + "bus": { + "title": "总线åˆ�始化", + "bas_path": "BAS 文件", + "choose": "选择 BAS…", + "download": "下载并åˆ�始化", + "reinit": "é‡�æ–°åˆ�始化", + "type": "总线类型", + "type_ecat": "EtherCAT", + "type_rtex": "RTEX", + "type_unknown": "未知", + "state": "åˆ�始化状æ€�", + "state_pending": "未完æˆ�", + "state_running": "正在åˆ�始化…", + "state_ok": "åˆ�始化æˆ�功", + "state_failed": "åˆ�始化失败", + "state_timeout": "åˆ�始化超时", + "nodes": "节点数é‡�", + "axes": "è½´æ•°é‡�", + "downloaded": "文件已下载,控制器将延时扫æ��总线", + "dialog_title": "选择 BAS 文件" + }, + "axes": { + "title": "è½´è¿�动", + "axis": "è½´å�·", + "enable": "使能", + "enable_on": "已使能", + "enable_off": "未使能", + "atype": "轴类型", + "units": "脉冲当é‡�", + "speed": "速度", + "accel": "加速度", + "dpos": "命令ä½�ç½®", + "mpos": "å��馈ä½�ç½®", + "dpos_mm": "命令ä½�ç½® (mm)", + "mpos_mm": "å��馈ä½�ç½® (mm)", + "status": "轴状æ€�", + "motion": "è¿�动状æ€�", + "motion_word": "è¿�动", + "idle": "å�œæ­¢", + "moving": "è¿�动中", + "position_pair": "{value} ({mm} mm)", + "enable_all": "全部使能", + "disable_all": "全部关闭使能", + "clear_alarm_all": "清除全部报警", + "detail": "选中轴 {axis}: 轴类型 {atype} · 加速度 {accel} · å��馈 {mpos} ({mpos_mm} mm) · 轴状æ€� {status} · {home}", + "detail_none": "选中轴 {axis}: å°šæ— æ•°æ�®ï¼ˆæœªè¿žæŽ¥æˆ–未åˆ�始化)", + "home_status": "回零状æ€�", + "home_ok": "回零完æˆ�", + "home_pending": "回零未完æˆ�", + "home_running": "回零中", + "jog_fwd": "正转", + "jog_rev": "å��转", + "stop_all": "全部å�œæ­¢", + "stop": "å�œæ­¢", + "target": "目标ä½�ç½®", + "move_abs": "ç»�对定ä½�", + "move_rel": "相对è¿�动", + "home_mode": "回零模å¼�", + "home_high": "回零高速", + "home_low": "回零低速", + "home_offset": "回零å��ç§»", + "clear_alarm": "清除报警", + "apply": "应用å�‚æ•°", + "home": "å�¯åŠ¨å›žé›¶", + "controls": "æ“�作", + "mm_derived_tip": "毫米值由脉冲当é‡�æ�¢ç®—得出,是显示值,ä¸�是标定长度。", + "enabled_count": "已使能 {on}/{total} è½´" + }, + "busdata": { + "title": "总线数æ�®äº¤äº’", + "group": "总线数æ�® (SDO / RTEX)", + "ecat": "EtherCAT (SDO)", + "rtex": "RTEX", + "node": "节点å�·", + "index": "对象字典", + "subindex": "å­�ç¼–å�·", + "type": "æ•°æ�®ç±»åž‹", + "value": "æ•°æ�®", + "read": "读", + "write": "写", + "axis": "è½´å�·", + "parameter": "å�‚数地å�€", + "last_result": "最近一次: {text}", + "not_available": "当å‰�总线ä¸�支æŒ�该æ“�作", + "read_ok": "读å�–æˆ�功: {value}", + "write_ok": "写入æˆ�功" + }, + "status": { + "motion": "è¿�动控制", + "ready": "就绪", + "poll_ok": "状æ€�刷新正常", + "poll_paused": "状æ€�刷新已暂å�œ", + "poll_stalled": "状æ€�刷新中断", + "last_error": "最近错误", + "none": "æ— ", + "refused": "æ“�作被拒ç»�", + "done": "完æˆ�", + "failed": "失败", + "pause": "æš‚å�œåˆ·æ–°", + "resume": "继续刷新", + "paused": "已暂å�œçжæ€�刷新", + "alarm_short": "报警", + "axes_enabled": "已使能 {on}/{total} è½´", + "health_none": "刷新 —", + "health_age": "刷新 {ms} ms å‰�", + "health_paused": "刷新已暂å�œ", + "poll_every": "刷新间隔 {ms} ms" + }, + "run": { + "title": "è¿�行", + "phase_idle": "空闲", + "phase_dry_run": "干跑中", + "phase_running": "è¿�行中", + "phase_paused": "已暂å�œ", + "phase_done": "已完æˆ�", + "phase_failed": "失败", + "step_of": "第 {done}/{total} 点", + "no_steps": "尚未生æˆ�标定点", + "elapsed_only": "已用 {elapsed}", + "elapsed_remaining": "已用 {elapsed} · 预计剩余 {remaining}", + "run": "è¿�行", + "pause": "æš‚å�œ", + "abort": "中止", + "abort_stopped": "已中止è¿�行并å�œæ­¢å…¨éƒ¨è½´ã€‚", + "not_implemented": "è¿�行器属于 P7 阶段,尚未实现;中止éš�æ—¶å�¯ç”¨ã€‚" + }, + "readout": { + "title": "轴状æ€�" + }, + "workspace": { + "tab_axes": "轴调试", + "tab_sequence": "测试åº�列", + "tab_sensor": "传感器", + "tab_surface": "表é�¢åˆ†å¸ƒ", + "tab_points": "标定点", + "not_built": "尚未实现", + "reserved_sensor": "传感器连接ã€�实时图åƒ�与力曲线将在此显示:设备与信å�·æº�选择ã€�预览ã€�以å�Šå’Œ Studio 类似的曲线视图(å� ç”¨ç©ºé—´æ›´å°�)。", + "reserved_surface": "多点标定的ç�µæ•�度将在此以色图å� åŠ åœ¨ä¼ æ„Ÿå™¨ä¸‰ç»´è¡¨é�¢ä¸Šï¼šæ¯�个标定点的ç�µæ•�度ç»�æ�’值ç�€è‰²ï¼Œå¹¶ç»™å‡ºè‰²æ ‡ä¸ŽèŒƒå›´ã€‚", + "reserved_points": "标定点列表与采集控制将在此显示:平å�°å��æ ‡ã€�å�‚考载è�·ã€�采样统计与ç�µæ•�度结果。" + }, + "sequence": { + "title": "测试åº�列设计", + "profile_title": "标定点生æˆ�", + "preview_title": "预览", + "validation_title": "校验", + "points_title": "标定点列表", + "field_mode": "曲é�¢æ¨¡å¼�", + "field_pitch": "点è·� (mm)", + "field_margin": "边界余é‡� (mm)", + "field_radius": "曲é�¢å�Šå¾„ (mm)", + "field_clear_height": "安全高度 (mm)", + "field_approach_speed": "进给速度 (mm/s)", + "field_press_speed": "下压速度 (mm/s)", + "field_press_depth": "下压深度 (mm)", + "field_dwell": "ä¿�压 (s)", + "field_settle": "稳定窗å�£ (s)", + "field_repeats": "é‡�å¤�次数", + "field_order": "é��历顺åº�", + "mode_flat": "å¹³é�¢", + "mode_curved": "曲é�¢", + "order_row": "按行", + "order_serpentine": "蛇形", + "order_distance": "按è·�离", + "preview_placeholder": "三维预览:传感器表é�¢ · 标定点 · é��历顺åº�\n(仿真阶段实现)", + "preview_hint": "生æˆ�å�Žå�¯åœ¨æ­¤æ£€æŸ¥ç‚¹æ˜¯å�¦è�½åœ¨æ›²é�¢ä¸Šã€�是å�¦åœ¨è¡Œç¨‹å†…ã€�接触能å�¦è¢«ç¡®è®¤ã€‚", + "validation_pending": "尚未生æˆ�标定点。", + "check_on_surface": "在曲é�¢èŒƒå›´å†…", + "check_within_travel": "在行程内", + "check_contact_confirmable": "接触å�¯ç¡®è®¤", + "btn_generate": "生æˆ�标定点", + "btn_simulate": "仿真", + "btn_dry_run": "干跑(ä¸�接触)", + "btn_save": "ä¿�å­˜åº�列…", + "btn_load": "载入åº�列…", + "pending_tooltip": "该功能属于 {phase} 阶段,尚未实现。", + "col_index": "#", + "col_x": "X (mm)", + "col_y": "Y (mm)", + "col_z": "Z (mm)", + "col_status": "状æ€�", + "col_sensitivity": "ç�µæ•�度(上次)", + "col_samples": "样本", + "col_actions": "æ“�作", + "not_generated": "—", + "status_pending": "待测", + "points_hint": "å��标由网格生æˆ�å¹¶é€�点校验,ä¸�接å�—手工输入:手工输入的å��标没有任何检查,一个错数字会æ¯�掉整æ�¡åº�列。" + }, + "action": { + "ok": "完æˆ�", + "refused": "已拒ç»�", + "failed": "失败" + }, + "diagnostics": { + "title": "诊断信æ�¯", + "binding": "Qt 绑定", + "dll_dir": "控制器库", + "prefs": "å��好文件", + "outputs": "输出目录", + "bas_loaded": "BAS 已加载", + "yes": "是", + "no": "å�¦" + } +} diff --git a/apps/autocal/config/preferences.default.json b/apps/autocal/config/preferences.default.json new file mode 100644 index 0000000..6dec09f --- /dev/null +++ b/apps/autocal/config/preferences.default.json @@ -0,0 +1,50 @@ +{ + "language": "zh_CN", + "theme": "light", + "window_geometry": null, + "connection": { + "mode": "eth", + "ip": "192.168.0.11", + "com_id": 0, + "card_num": 0, + "local_string": "0" + }, + "bas_path": "", + "units_per_mm": 10.0, + "state_poll_ms": 100, + "sdo_last": { "node": 0, "index": 24704, "subindex": 0, "type": 0 }, + "rtex_last": { "axis": 0, "parameter": 0 }, + "show_diagnostics": false, + "safety_limits": { + "max_travel_mm": 100.0, + "max_descent_mm": 10.0, + "max_press_depth_mm": 1.0, + "follow_error_mm": 0.5, + "contact_search_mm": 3.0 + }, + "sensor_setup": { + "sensor_type": "box", + "config_ref": "", + "geometry_id": "", + "press_axis": 2, + "probe_tip_offset_mm": [0.0, 0.0, 0.0], + "probe_tip_set": false, + "sensor_to_platform": [], + "transform_residual_mm": 0.0, + "transform_feature_count": 0 + }, + "sequence_profile": { + "mode": "flat", + "pitch_mm": 2.0, + "margin_mm": 1.0, + "radius_mm": 0.0, + "clear_height_mm": 5.0, + "approach_speed_mm_s": 2.0, + "press_speed_mm_s": 0.5, + "press_depth_mm": 0.2, + "dwell_s": 0.5, + "settle_window_s": 0.5, + "repeats": 3, + "order": "serpentine" + } +} diff --git a/apps/autocal/docs/workflow.md b/apps/autocal/docs/workflow.md new file mode 100644 index 0000000..ff2bdeb --- /dev/null +++ b/apps/autocal/docs/workflow.md @@ -0,0 +1,335 @@ +# AutoCal workflow — platform bring-up to calibration data + +This is the operating workflow the application is built around. It is written to +be argued with: each phase names what it needs, what it produces, why it exists, +and what must be true before the next one starts. + +A **gate** is a checkpoint. The app's job at a gate is to make the operator's +answer trustworthy, not to ask "are you sure?". + +```text +P0 Session & safety baseline [gate] +P1 ECAT setup [gate] +P2 Homing + test motion [gate] +P3 Sensor & probe setup [gate] +P4 Coordinate setup: sensor <-> platform [gate] +P5 Sequence design [gate] +P6 Simulate & dry run [gate] +P7 Run + acquire [gate] +P8 Store raw +P9 Post-process [gate] +P10 Calibration export +P11 Verify [gate] +``` + +--- + +## P0 — Session & safety baseline + +**Why:** the machine can move from P2 onward. Everything that limits that motion +should be decided while nothing is moving. + +**Inputs:** operator identity, machine/bench identifier, units per axis, +`units_per_mm`. + +**Safety limits, set here, enforced from P2:** + +| Limit | Meaning | +|---|---| +| maximum travel per axis | never commanded past this, in user units | +| maximum Z descent per step | a single approach may not exceed this | +| press-depth ceiling | the press depth a sequence profile is allowed to request | +| no-contact abort | if contact is not detected within the descent budget, stop the run | +| following-error bound | how far a drive may lag before the move counts as stalled | + +**Outputs:** a session record — who, when, which machine, and the limits in +force. Stamped onto everything the session produces. + +**Gate:** every limit has a value; no limit is "unset" (an unset limit is an +unlimited machine). + +**Currently missing from the workflow:** this phase did not exist. Without it, +step 1 went from bus setup straight to jogging five axes. + +--- + +## P1 — ECAT setup + +**Why:** nothing else can be trusted until the controller has actually found its +slaves. A missing drive discovered at P7 wastes the whole run. + +**Inputs:** controller address, the matching `.bas` program +(`ECAT.bas` / `RTEX.bas`). + +**Steps:** connect → download and run the BAS program → watch +`Bus_InitStatus` to completion or timeout → read `BUS_TYPE`, +`Bus_TotalAxisnum`, and the node count. + +**Outputs:** bus type, node count, axis count, init status. + +**Gate:** init status is success, **and** the node/axis counts match what this +bench is expected to have. A mismatch is a finding, not a warning. + +**Existing code:** `MotionService.download_bas` / `poll` already implement the +state machine and the 25 s timeout. + +--- + +## P2 — Homing + test motion + +**Why:** a drive that homes and jogs cleanly at low speed is the precondition for +letting a sequence drive it. + +**Inputs:** per-axis homing mode, speeds, offset; a test-motion profile. + +**Steps:** enable → home each axis → low-speed jog each axis in both directions +→ confirm movement and return. + +**Outputs:** per-axis pass/fail with the evidence (distance moved, following +error, alarm state). + +**Gate — the test motion needs a criterion.** This was missing. Proposed: + +- the axis moved at least a configured minimum distance; +- it returned within a tolerance of its start position; +- following error stayed under the bound from P0; +- no alarm before, during or after. + +**Existing code:** the axis panel does the jog/homing; the criterion is new. + +--- + +## P3 — Sensor & probe setup + +**Why:** which sensor is under test, what touches it, and how far the touching +point is from the axis reference. Contact depth is meaningless without the tool. + +**Inputs:** + +- sensor type and config reference (the same `config/sensors//sensor.json` + the studio uses); +- the sensor's 3D scene package and its `geometry_id`; +- the **probe tip** (tool): which axis presses, and the tip offset from the + platform's reference point; +- the mounting pose: how the sensor is fixtured on the platform. + +**Outputs:** a saved setup block. + +**Gate:** + +- the calibration's `geometry_id` matches the scene's, or the app says which is + which rather than colouring the wrong geometry; +- the probe tip offset is set (a zero offset that was never entered is not the + same as a probe at the origin). + +--- + +## P4 — Coordinate setup: sensor ↔ platform + +**Why this is the load-bearing phase.** "Which points on the sensor should be +tested" is a question in the *sensor's* frame. The platform moves in *platform* +coordinates. Without the transform between them: + +- a generated grid cannot be checked against the sensor's surface; +- a measured point cannot be placed on that surface for the sensitivity map; +- a calibration cannot be re-applied to a re-fixtured sensor. + +**Inputs:** the probe tip from P3, and a set of **at least four non-collinear +known features** on the sensor or its fixture, probed in platform coordinates. + +**Steps:** probe each feature → solve for the rigid transform (rotation and +translation; scale if the units are not yet trusted) → report the residual. + +**Outputs:** a 4×4 transform from sensor frame to platform frame, with its +residual and the feature set that produced it. + +**Gate:** residual under a configured tolerance, and at least four non-collinear +points (four collinear points determine a rotation about that line, not a +transform — the app should refuse that set rather than return a number). + +**Not built in this step:** the solver. Only the workflow step, the stored +transform, and the place its residual is displayed. + +--- + +## P5 — Sequence design + +**Why:** this is the decision the whole run exists to carry out. + +**Inputs (the profile, persisted and reusable):** + +| Parameter | Meaning | +|---|---| +| mode | flat surface or curved (a cylindrical/spherical sensor area) | +| grid pitch | spacing between points, in mm | +| margin / radius | how far in from the active edge the grid stays; radius for curved | +| clear height | standoff above the surface to travel at | +| approach speed | descent speed before contact | +| press speed | descent speed after contact | +| press depth | how far past contact to press | +| dwell | how long to hold the press | +| settle window | how long to wait before the measurement is considered settled | +| repeats | how many times each point is measured | +| point order | row-wise, serpentine, or by distance | + +**Steps:** generate the grid → validate **every point** three ways: + +1. **on the sensor** — its sensor-frame position falls on the modelled surface, + not off the edge and not inside a hole; +2. **within travel** — the platform can reach it inside the P0 limits; +3. **confirmable** — the sensor's sensing area is under the point, so a contact + there can actually be seen by the sensor. + +**Outputs:** a point list with a sensor-frame position and a platform-frame +target for each, plus a validation verdict per point. + +**Gate:** no point may be unusable without being marked as such. A run may +proceed with skipped points if the operator says so explicitly; it may not +proceed with unvalidated ones. + +**The interface:** three zones in the Sequence tab — profile parameters, a 3D +preview of the surface with the points and their visiting order, and the +validation list. See `apps/autocal/README.md`. + +--- + +## P6 — Simulate & dry run + +**Why:** a wrong Z or a wrong grid is much cheaper to find now. + +**Simulate:** play the full toolpath in the 3D view — a moving marker with a +phase label (standoff → descend → press → dwell → retract), the estimated +duration, and per-point reachability. No motion. + +**Dry run:** execute the real XY path, descending only to the clear height, +never pressing. This proves the geometry, the ordering, and the timing. + +**Outputs:** estimated duration, per-point reachability, and (dry run) the +measured time per point. + +**Gate:** the operator has seen the path and a dry run has completed without a +guard violation. + +--- + +## P7 — Run + acquire + +**Why:** the only phase that touches the sensor. + +**Per step, in order:** + +1. move to the point's XY at clear height; +2. descend at approach speed; +3. **detect contact by position** — the axis stalls / following error rises + while descending; +4. **confirm with the sensor under test** — its reading must change when the + probe presses. This is the second opinion: position says "I stopped moving", + the sensor says "something real is happening"; +5. press to the configured depth at press speed; +6. dwell, then acquire the settle window; +7. retract, move to the next point. + +**Failure semantics (per point, because resume needs them):** + +| Outcome | Run behaviour | +|---|---| +| contact confirmed | record the point as measured | +| contact detected, sensor shows no change | mark **suspect**, continue; a suspect point is visible, not silently averaged in | +| no contact within the descent budget | **abort** the run (P0 limit) and leave the point unfinished | +| guard violation | abort the run, name the step and the limit | + +**Writes are incremental**: each point is written as it completes, with its +status, so an interrupted run resumes at the first unfinished point and partial +data stays valid. + +**Existing code:** the motion layer has the guards and the poller; the step +machine, the contact detection and the acquisition binding are new. + +--- + +## P8 — Store raw + +**Why:** post-processing will change. The raw record must not. + +**Contents:** + +- the sequence profile and the instantiated point list (positions and order); +- per point: the position trace, the raw sensor series, the settle window used, + timings, the contact position, and the status; +- the setup: sensor identity, geometry id, probe tip, the P4 transform and its + residual; +- the session record and app/version stamps from P0. + +Nothing derived is authoritative. The requirement that makes this reproducible: +**a dataset can be regenerated from the profile, the setup and the raw series**, +with no dependency on a previously computed result. + +--- + +## P9 — Post-process + +**Why:** turning samples into a number is a decision, and it has to be visible. + +**Steps:** choose the settle window per point → stability check (is the point +actually settled, or drifting?) → sensitivity per point per axis → outlier +detection → residual per point → fit the surface across points. + +**Outputs:** per-point sensitivity with a quality verdict, and a surface field +over the sensor's geometry ready for the 3D colour map. + +**Gate:** every point is either valid or explicitly excluded, with a reason. +A point with too few samples or a drifting window is not a coefficient. + +--- + +## P10 — Calibration export + +**Per-point results and the fitted surface, explicitly linked.** They are one +calibration; exporting them as unrelated artefacts is how a map ends up in a +report that does not match the table beside it. + +--- + +## P11 — Verify + +**Why this exists:** P9 and P10 produce numbers. Nothing yet checks they are +right. "Post process → calibration data" had no acceptance test. + +**Steps:** apply the calibration → re-measure a small subset of the points → +compare the measured sensitivity against what the calibration predicts → +report the discrepancy per point. + +**Gate:** the discrepancy is within tolerance, or the verification is recorded +as failed. A calibration that was never verified is labelled as such wherever it +is used. + +--- + +## Gaps this document closes + +| # | Gap | Phase | +|---|---|---| +| 1 | no limits before motion | P0 | +| 2 | no session identity (who/when/which machine) | P0 | +| 3 | the sensor↔platform transform was absent | P4 | +| 4 | the probe tip (tool) was never defined | P3 | +| 5 | the test motion had no pass/fail criterion | P2 | +| 6 | grid validity was not checked per point | P5 | +| 7 | the settle window and stability criterion were undefined | P9 | +| 8 | a point's failure had no defined behaviour | P7 | +| 9 | the sequence was transient, not a saved, versioned artefact | P8 | +| 10 | no verification pass | P11 | + +## Data flow + +```text +P0 limits ─┬───────────────────────────────> enforced at P2, P6, P7 +P1 bus ────┴─> axis availability ─────────> P2 +P3 sensor setup ─┬─ geometry id ──────────> P5 validation, P10 surface + └─ probe tip ────────────> P4, P7 press depth +P4 transform ─────> sensor <-> platform ──> P5 point generation, P9 map + (residual) P11 re-fixture check +P5 profile + points ──> P6 simulate/dry run ──> P7 run +P7 raw series ─────> P8 dataset ──> P9 post-process ──> P10 export + └─> P11 verify +``` diff --git a/apps/autocal/pyproject.toml b/apps/autocal/pyproject.toml new file mode 100644 index 0000000..db9d657 --- /dev/null +++ b/apps/autocal/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "orisys-autocal" +version = "0.1.0" +description = "Orisys AutoCal — internal 5-axis calibration platform control" +requires-python = ">=3.10" +authors = [{ name = "Orisys-Tech" }] +dependencies = [ + # PySide6 is the SQL-free, LGPLv3 Qt binding. It is deliberate, not a + # preference: this is an internal tool, and PyQt5's free option is GPLv3 + # (see README.md and src/orisys_autocal/qtcompat.py). + "PySide6>=6.6", + "orisys-app-shared>=0.1.0", +] + +[project.scripts] +orisys-autocal = "orisys_autocal.main:main" + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/apps/autocal/run.py b/apps/autocal/run.py new file mode 100644 index 0000000..fc4ece0 --- /dev/null +++ b/apps/autocal/run.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Launch Orisys AutoCal from the source tree.""" + +from __future__ import annotations + +import os +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)) + +# Resolve config/outputs to this app, and set the app id before any shared +# import can read it (see orisys_app_shared.paths). +os.environ.setdefault("ORISYS_APP_ID", "autocal") +os.chdir(ROOT) + +from orisys_autocal.main import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/autocal/src/orisys_autocal/__init__.py b/apps/autocal/src/orisys_autocal/__init__.py new file mode 100644 index 0000000..9fce524 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/__init__.py @@ -0,0 +1,12 @@ +"""Orisys AutoCal — internal 5-axis calibration platform control app.""" + +__version__ = "0.1.0" +REQUIRES_SHARED_API = "0.1" + +#: Window / QApplication name. +APP_DISPLAY_NAME = "Orisys AutoCal" + +#: The Qt binding this app uses. It is PySide6 and only PySide6: LGPLv3, so an +#: internal closed-source tool needs no licence purchase, and it is the +#: maintained Qt-for-Python binding (PySide2 is discontinued upstream). +QT_BINDING = "PySide6" diff --git a/apps/autocal/src/orisys_autocal/main.py b/apps/autocal/src/orisys_autocal/main.py new file mode 100644 index 0000000..b994088 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/main.py @@ -0,0 +1,126 @@ +"""AutoCal entrypoint: logging, locale, theme, then the window.""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys + +# The app id must be set before anything imports orisys_app_shared.paths, because +# the config/output roots are resolved on first use and cached by callers. +os.environ.setdefault("ORISYS_APP_ID", "autocal") + + +def _force_utf8_streams() -> None: + """Make stdout/stderr UTF-8 where the platform lets us. + + The controller's error strings and every panel label are Chinese, and a + Windows console defaults to a legacy code page that cannot encode them. + A failed reconfigure is not fatal — the log file is UTF-8 regardless. + """ + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + continue + try: + reconfigure(encoding="utf-8", errors="replace") + except (ValueError, OSError): # pragma: no cover - stream already detached + pass + + +def _pyside_version() -> str: + import PySide6 + + return getattr(PySide6, "__version__", "unknown") + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(prog="orisys-autocal", description=__doc__) + parser.add_argument( + "--demo", + action="store_true", + help="start on the simulated platform (no hardware, no controller DLL)", + ) + parser.add_argument( + "--log-level", + default="INFO", + choices=("DEBUG", "INFO", "WARNING", "ERROR"), + help="console and file log level", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(list(argv if argv is not None else sys.argv[1:])) + _force_utf8_streams() + + 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.theme import build_qss + from PySide6.QtWidgets import QApplication + + from . import REQUIRES_SHARED_API, __version__ as app_version + from .motion import ConnectionSpec, DemoTransport + from .preferences import load_preferences + from .ui.window import AutoCalMainWindow + + init_logging("autocal", level=getattr(logging, args.log_level)) + install_exception_hooks() + check_shared_compat(REQUIRES_SHARED_API) + + logger = logging.getLogger(__name__) + 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[:1]) + app.setApplicationName("Orisys AutoCal") + app.setApplicationVersion(app_version) + app.setStyleSheet(build_qss(theme)) + + transport = None + if args.demo: + transport = DemoTransport() + connection = dict(prefs.get("connection") or {}) + connection["mode"] = "demo" + prefs["connection"] = connection + + window = AutoCalMainWindow(prefs=prefs) + if transport is not None: + window.poller.service._transport = transport # noqa: SLF001 - deliberate swap + window.show() + + logger.info( + "AutoCal started (version=%s shared=%s api=%s binding=PySide6 %s " + "theme=%s language=%s demo=%s)", + app_version, + shared_version, + API_VERSION, + _pyside_version(), + theme, + language, + args.demo, + ) + if args.demo: + window.connect_controller(ConnectionSpec(mode="demo"), label="connect") + + code = int(app.exec()) + + # Leave the process immediately. The ZMotion runtime keeps native threads of + # its own, so a normal interpreter shutdown can hang after the window closes; + # everything durable was already flushed by closeEvent and the log handlers. + logging.shutdown() + for stream in (sys.stdout, sys.stderr): + try: + stream.flush() + except Exception: # noqa: BLE001 - a closed stream must not block exit + pass + os._exit(code) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/autocal/src/orisys_autocal/motion/__init__.py b/apps/autocal/src/orisys_autocal/motion/__init__.py new file mode 100644 index 0000000..749e6fe --- /dev/null +++ b/apps/autocal/src/orisys_autocal/motion/__init__.py @@ -0,0 +1,58 @@ +"""Platform control: models, guards, polling, and the Qt worker bridge.""" + +from .models import ( + INIT_FAILED, + INIT_OK, + INIT_RUNNING, + INIT_TIMEOUT_S, + AxisParams, + AxisState, + AxisStatusBits, + BusStatus, + BusType, + ConnectionMode, + ConnectionSpec, + MotionResult, + Snapshot, +) +from .poller import MotionPoller +from .service import PLATFORM_AXES, MotionService +from .transport import DemoTransport, NativeTransport, ZmcTransport +from .units import ( + DEFAULT_ACCEL, + DEFAULT_SPEED, + DEFAULT_UNITS, + PULSES_PER_MM, + mm_to_user_units, + safe_float, + user_units_to_mm, +) + +__all__ = [ + "DEFAULT_ACCEL", + "DEFAULT_SPEED", + "DEFAULT_UNITS", + "INIT_FAILED", + "INIT_OK", + "INIT_RUNNING", + "INIT_TIMEOUT_S", + "PLATFORM_AXES", + "PULSES_PER_MM", + "AxisParams", + "AxisState", + "AxisStatusBits", + "BusStatus", + "BusType", + "ConnectionMode", + "ConnectionSpec", + "DemoTransport", + "MotionPoller", + "MotionResult", + "MotionService", + "NativeTransport", + "Snapshot", + "ZmcTransport", + "mm_to_user_units", + "safe_float", + "user_units_to_mm", +] diff --git a/apps/autocal/src/orisys_autocal/motion/errors.py b/apps/autocal/src/orisys_autocal/motion/errors.py new file mode 100644 index 0000000..b7cd5c4 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/motion/errors.py @@ -0,0 +1,77 @@ +"""Translate controller return codes into something an operator can act on. + +Only the codes the vendor actually documents are given a meaning here. The +`sdk header <../../resources/zmotion/x64/zauxdll2.h>`_ defines the auxiliary +error block at offset 30000: + + #define ERR_NOACK ERR_AUX_OFFSET //无应答 + #define ERR_ACKERROR (ERR_AUX_OFFSET+1) //应答错误 + #define ERR_AUX_PARAERR (ERR_AUX_OFFSET+2) //å�‚数错误 + #define ERR_AUX_NOTSUPPORT (ERR_AUX_OFFSET+3) //ä¸�支æŒ� + #define ERR_AUX_FILE_ERROR (ERR_AUX_OFFSET+4) //å�‚数错误 + #define ERR_AUX_OS_ERR (ERR_AUX_OFFSET+5) // + +Everything else is the controller's own number. :func:`describe` deliberately +does **not** invent a meaning for those: it names the number and points at +ZDevelop, which is where the firmware's code table lives. A guessed message on +a motion controller is worse than "unknown". +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +#: Auxiliary (host-side) error block offset, from ``zauxdll2.h``. +ERR_AUX_OFFSET = 30000 + +#: Meaning per code, as ``(english, chinese)``. +MESSAGES: dict[int, tuple[str, str]] = { + ERR_AUX_OFFSET: ("No response from the controller", "控制器无应答"), + ERR_AUX_OFFSET + 1: ("The controller rejected the command", "应答错误"), + ERR_AUX_OFFSET + 2: ("Wrong parameter", "å�‚数错误"), + ERR_AUX_OFFSET + 3: ("Not supported by this model or firmware", "ä¸�支æŒ�该功能"), + ERR_AUX_OFFSET + 4: ("File error", "文件错误"), + ERR_AUX_OFFSET + 5: ("Operating-system error in the library", "库内æ“�作系统错误"), +} + +#: The last-resort advice appended to every unknown code. +UNKNOWN_HINT = ( + "Look this number up in ZDevelop's error table (控制器错误ç �表), " + "or run the same command from ZDevelop to see the decoded fault." +) + + +def describe(code: int, *, language: str = "zh_CN") -> str: + """A human-readable message for a controller return code. + + Args: + code: the value returned by a ``ZAux_*`` call. + language: ``zh_CN`` for Chinese, anything else for English. + + Returns: + ``"0"`` is reported as success; known codes get a translated message; + unknown codes keep their number visible rather than being guessed at. + """ + code = int(code) + if code == 0: + return "æˆ�功" if language == "zh_CN" else "OK" + + index = 1 if language == "zh_CN" else 0 + known = MESSAGES.get(code) + if known is None: + # An unknown code that still resolves to an auxiliary slot is more + # useful than a bare integer, but only when it is documented. + logger.debug("Undocumented ZMotion return code %s", code) + prefix = "错误ç �" if language == "zh_CN" else "Error code" + return f"{prefix} {code}" + return known[index] + + +def describe_with_hint(code: int, *, language: str = "zh_CN") -> str: + """As :func:`describe`, plus the ZDevelop pointer for unknown codes.""" + message = describe(code, language=language) + if int(code) == 0 or int(code) in MESSAGES: + return message + return f"{message} — {UNKNOWN_HINT}" diff --git a/apps/autocal/src/orisys_autocal/motion/models.py b/apps/autocal/src/orisys_autocal/motion/models.py new file mode 100644 index 0000000..3fa1806 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/motion/models.py @@ -0,0 +1,180 @@ +"""Motion-control domain model. + +Plain frozen dataclasses and no Qt: the service layer and the tests both use +these, and neither needs a GUI to do it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +#: Which physical transport the controller is reached over. +ConnectionMode = Literal["eth", "com", "pci", "local", "demo"] + +#: Bus families the ``.bas`` programs report through ``BUS_TYPE``. +BusType = Literal["unknown", "ecat", "rtex"] + +#: The vendor reference's window between "BAS downloaded" and a final status. +INIT_TIMEOUT_S = 25.0 + +#: ``Bus_InitStatus`` values, from ``ECAT.bas``. +INIT_RUNNING = -1 +INIT_FAILED = 0 +INIT_OK = 1 + + +@dataclass(frozen=True) +class ConnectionSpec: + """How to reach the controller.""" + + mode: ConnectionMode + ip: str = "" + com_id: int = 0 + card_num: int = 0 + local_string: str = "0" + + def describe(self) -> str: + if self.mode == "eth": + return self.ip or "(no IP)" + if self.mode == "com": + return f"COM{self.com_id}" + if self.mode == "pci": + return f"PCI card {self.card_num}" + if self.mode == "local": + return f"local {self.local_string}" + return "demo (no hardware)" + + +@dataclass(frozen=True) +class AxisParams: + """Per-axis parameters as entered by the operator.""" + + units: float = 10.0 + speed: float = 1000.0 + accel: float = 10000.0 + creep: float = 10.0 + home_mode: int = 0 + home_high: float = 50.0 + home_low: float = 10.0 + home_offset: float = 0.0 + + +@dataclass(frozen=True) +class AxisStatusBits: + """Decoded ``ZAux_Direct_GetAxisStatus`` word. + + Only the bits the AutoCal panels display are named. Bit 6 is the one the + vendor reference tests (``status & 64``) to decide that homing is running. + """ + + raw: int = 0 + + @property + def home_in_progress(self) -> bool: + """Bit 6 — set while the drive is executing a homing move.""" + return bool(self.raw & 64) + + @property + def moving(self) -> bool: + """Bit 1 — the axis is producing motion.""" + return bool(self.raw & 2) + + @property + def alarm(self) -> bool: + """Bit 9 — the drive is reporting a fault.""" + return bool(self.raw & 512) + + def active_bits(self) -> list[int]: + """Every set bit position, for the status column's tooltip.""" + return [bit for bit in range(32) if self.raw & (1 << bit)] + + +@dataclass(frozen=True) +class AxisState: + """One axis as read back from the controller.""" + + axis: int + enabled: bool = False + atype: int = 0 + units: float = 0.0 + speed: float = 0.0 + accel: float = 0.0 + dpos: float = 0.0 + mpos: float = 0.0 + status: AxisStatusBits = field(default_factory=AxisStatusBits) + idle: bool = True + home_status: int = 0 + read_ok: bool = True + error: str = "" + + +@dataclass(frozen=True) +class BusStatus: + """Bus-level state, from the ``.bas`` program's globals.""" + + bus_type: BusType = "unknown" + init_status: int = INIT_RUNNING + node_count: int = 0 + total_axes: int = 0 + bas_path: str = "" + bas_loaded: bool = False + init_started_at: float | None = None + message: str = "" + + @property + def initialized(self) -> bool: + return self.init_status == INIT_OK + + @property + def supports_sdo(self) -> bool: + return self.bus_type == "ecat" + + @property + def supports_rtex(self) -> bool: + return self.bus_type == "rtex" + + def timed_out(self, now: float) -> bool: + """True while a BAS run started long ago has still not reported.""" + if self.init_status != INIT_RUNNING or self.init_started_at is None: + return False + return (now - self.init_started_at) > INIT_TIMEOUT_S + + +@dataclass(frozen=True) +class Snapshot: + """Everything the UI needs for one refresh.""" + + connected: bool = False + spec: ConnectionSpec | None = None + bus: BusStatus = field(default_factory=BusStatus) + axes: tuple[AxisState, ...] = () + error: str = "" + + def axis(self, index: int) -> AxisState | None: + for state in self.axes: + if state.axis == index: + return state + return None + + +@dataclass(frozen=True) +class MotionResult: + """Outcome of a command, with a reason when it was refused or failed.""" + + ok: bool + code: int = 0 + message: str = "" + + @classmethod + def success(cls, message: str = "") -> "MotionResult": + return cls(ok=True, code=0, message=message) + + @classmethod + def refused(cls, message: str) -> "MotionResult": + """The command was not sent: a guard rejected it before any motion.""" + return cls(ok=False, code=0, message=message) + + @classmethod + def failed(cls, code: int, message: str) -> "MotionResult": + return cls(ok=False, code=int(code), message=message) diff --git a/apps/autocal/src/orisys_autocal/motion/poller.py b/apps/autocal/src/orisys_autocal/motion/poller.py new file mode 100644 index 0000000..4ae065e --- /dev/null +++ b/apps/autocal/src/orisys_autocal/motion/poller.py @@ -0,0 +1,312 @@ +"""Owns the controller connection on a worker thread. + +Why a thread and not a ``QTimer`` in the GUI thread: ``ZAux_BasDown`` (a ROM +write) and a bus scan block for seconds, and the bus-init sequence blocks for +three; a synchronous call on the GUI thread freezes the window for that whole +time. The Qt signal boundary here is the *only* way the UI reaches the +controller, which also means there is exactly one thread making DLL calls — +the ZMotion handle is not safe to share. + +Contract: + +* Creating the poller touches no hardware and loads no DLL. +* :meth:`MotionPoller.submit` queues a service call; the result comes back on + ``commandFinished``/``commandFailed``, and commands run in submission order. +* Only one service call is in flight at once, because the worker is a single + thread and each due step performs one control action. +* A call that raises is reported, not fatal: the timer keeps running and the + next snapshot still arrives. +""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, Iterable + +from PySide6.QtCore import ( + QMetaObject, + QObject, + Qt, + QThread, + QTimer, + Signal, + Slot, +) + +from .models import Snapshot +from .service import PLATFORM_AXES, MotionService + +logger = logging.getLogger(__name__) + +#: Default UI refresh interval, matching the reference program's 100 ms timer. +DEFAULT_INTERVAL_MS = 100 + + +class _Worker(QObject): + """Lives on the poller thread. Every controller call happens here.""" + + snapshotReady = Signal(object) + commandFinished = Signal(int, object) + commandFailed = Signal(int, str) + connected = Signal(object) + disconnected = Signal() + + def __init__(self, service: MotionService, axes: Iterable[int] | None) -> None: + super().__init__() + self._service = service + self._axes = list(axes) if axes is not None else None + # Both are created in ``warm_up``, which runs on the worker thread: a + # QTimer must be created, started and stopped from the thread that owns + # it, and this object is constructed on the GUI thread. + self._timer: QTimer | None = None + self._timer_loop: QTimer | None = None + self._interval_ms = DEFAULT_INTERVAL_MS + self._pending: list[tuple[int, Callable[[], Any]]] = [] + self._counter = 0 + self._last_snapshot_at = 0.0 + + # ------------------------------------------------------------ thread setup + + @Slot() + def warm_up(self) -> None: + """Create the timers and start polling. + + Invoked on the worker thread, once, at thread start-up — and taking no + arguments precisely so it can be, because marshalling an argument across + the meta-object system differs between the Qt bindings. + """ + if self._timer is None: + self._timer = QTimer(self) + self._timer.timeout.connect(self._tick) + + # A zero-interval timer that always stays active. Its only job is to + # keep the thread's event loop turning (``QThread.exec`` returns as + # soon as no events are left, even though the thread stays alive), so + # a queued ``submit`` is delivered even while polling is stopped. + self._timer_loop = QTimer(self) + self._timer_loop.timeout.connect(lambda: None) + self._timer_loop.start(0) + + self._timer.setInterval(self._interval_ms) + if not self._timer.isActive(): + self._timer.start() + + def start_polling(self, interval_ms: int = DEFAULT_INTERVAL_MS) -> None: + """Start (or retune) the poll timer. Must run on the worker thread.""" + self._interval_ms = max(20, int(interval_ms)) + if self._timer is None: + self.warm_up() + assert self._timer is not None + self._timer.setInterval(self._interval_ms) + if not self._timer.isActive(): + self._timer.start() + + @Slot() + def stop_polling(self) -> None: + if self._timer is not None: + self._timer.stop() + if self._timer_loop is not None: + self._timer_loop.stop() + + @Slot() + def cleanup(self) -> None: + """Destroy the timers on the thread that owns them. + + A QTimer stopped or deleted from another thread warns ("Timers cannot be + stopped from another thread") and leaves the timestamp bookkeeping in an + undefined state. Since these are parented to the worker, they would + otherwise be destroyed with it, from whichever thread drops the last + reference to the worker. + """ + for timer in (self._timer, self._timer_loop): + if timer is not None: + timer.stop() + timer.deleteLater() + self._timer = None + self._timer_loop = None + + def set_axes(self, axes: Iterable[int]) -> None: + self._axes = list(axes) + + def set_language(self, language: str) -> None: + self._service.set_language(language) + + # ------------------------------------------------------------------ queue + + def submit(self, action: Callable[[MotionService], Any]) -> int: + """Queue ``action``; returns the id its result will be reported under. + + Queuing is thread-safe (list append under the GIL); the *work* is not + done here. ``invokeMethod`` with a queued connection is what runs + :meth:`_tick` on the worker thread, so no controller call ever happens + on the caller's thread — which matters twice over, because the DLL is + single-threaded and a blocking call would freeze the window. + """ + self._counter += 1 + token = self._counter + self._pending.append((token, action)) + QMetaObject.invokeMethod(self, "_tick", Qt.ConnectionType.QueuedConnection) + return token + + @Slot() + def _tick(self) -> None: + """One poll cycle: run at most one queued command, then publish state. + + One control action per step, then a snapshot. Draining the whole queue + here would make a slow BAS download delay every later command with no + feedback in between. + + Qt looks this slot up by name (see ``submit``), so it keeps both the + decorator and the leading underscore. + """ + if self._pending: + token, action = self._pending.pop(0) + try: + result = action(self._service) + except Exception as exc: # noqa: BLE001 - report, never kill the thread + logger.exception("Controller command %s failed", token) + self.commandFailed.emit(token, self._describe(exc)) + else: + self.commandFinished.emit(token, result) + + self._emit_snapshot() + + def _emit_snapshot(self) -> None: + try: + snapshot = self._service.poll(self._axes) + except Exception as exc: # noqa: BLE001 - a poll must never stop the timer + logger.exception("Polling the controller failed") + snapshot = Snapshot( + connected=self._service.connected, + spec=self._service.spec, + bus=self._service.bus, + error=self._describe(exc), + ) + self.snapshotReady.emit(snapshot) + + def _describe(self, exc: Exception) -> str: + return self._service.describe_error(exc) + + +class MotionPoller(QObject): + """GUI-thread facade over the worker.""" + + snapshotReady = Signal(object) + commandFinished = Signal(int, object) + commandFailed = Signal(int, str) + connectionChanged = Signal(bool) + + def __init__( + self, + service: MotionService | None = None, + *, + axes: Iterable[int] | None = None, + parent: QObject | None = None, + ) -> None: + super().__init__(parent) + self.service = service or MotionService() + self._axes = list(axes) if axes is not None else list( + range(self.service.axis_count or PLATFORM_AXES) + ) + self._thread = QThread() + self._worker = _Worker(self.service, self._axes) + self._worker.moveToThread(self._thread) + self._worker.snapshotReady.connect(self.snapshotReady) + self._worker.commandFinished.connect(self.commandFinished) + self._worker.commandFailed.connect(self.commandFailed) + self._worker.connected.connect(lambda _spec: self.connectionChanged.emit(True)) + self._worker.disconnected.connect(lambda: self.connectionChanged.emit(False)) + # Kicks the worker's event loop into life on the new thread. + self._thread.started.connect(self._on_thread_started) + self._thread.start() + self._interval_ms = DEFAULT_INTERVAL_MS + + def _on_thread_started(self) -> None: + """Ask the worker to build its timers, on its own thread. + + ``QMetaObject.invokeMethod`` with a queued connection is used rather + than calling :meth:`_Worker.warm_up` here: this slot runs on the GUI + thread (the receiver is ``self``), and a QTimer created here would then + belong to the GUI thread while being started and stopped from the + worker — which Qt refuses at run time. + """ + QMetaObject.invokeMethod( + self._worker, "warm_up", Qt.ConnectionType.QueuedConnection + ) + + @property + def interval_ms(self) -> int: + return self._interval_ms + + def set_interval_ms(self, value: int) -> None: + """Change the refresh rate. Callable from the GUI thread.""" + self._interval_ms = max(20, int(value)) + self._submit_void(lambda worker: worker.start_polling(self._interval_ms)) + + def set_axes(self, axes: Iterable[int]) -> None: + self._axes = list(axes) + self._submit_void(lambda worker: worker.set_axes(self._axes)) + + def set_language(self, language: str) -> None: + self._submit_void(lambda worker: worker.set_language(language)) + + def pause(self) -> None: + """Stop the periodic state poll. + + The connection is untouched: pausing only stops asking. The event loop + keeps turning, so queued commands and shutdown still work while paused. + """ + self._paused = True + self._submit_void(lambda worker: worker.stop_polling()) + + def resume(self) -> None: + """Resume the periodic state poll after :meth:`pause`.""" + self._paused = False + self._submit_void(lambda worker: worker.start_polling(self._interval_ms)) + + @property + def paused(self) -> bool: + return self._paused + + #: Whether the caller has paused the poll (see :meth:`pause`). + _paused = False + + def _submit_void(self, action: Callable[[_Worker], None]) -> None: + """Hand a fire-and-forget call to the worker through its event loop.""" + self._worker.submit(lambda _service: action(self._worker)) + + def submit(self, action: Callable[[MotionService], Any]) -> int: + """Queue a service call. Returns the id it will be reported under.""" + return self._worker.submit(action) + + def connect_controller(self, spec) -> int: + return self.submit(lambda service: service.connect(spec)) + + def disconnect_controller(self) -> int: + def _disconnect(service: MotionService): + # Stop the axes before closing the handle: the reference program + # closes immediately and leaves the platform coasting. + service.stop_all() + service.disconnect() + return None + + return self.submit(_disconnect) + + def shutdown(self, timeout_ms: int = 5000) -> None: + """Stop polling and join the thread. Safe to call more than once.""" + if not self._thread.isRunning(): + return + # Both hops must run on the worker thread, because that is where the + # timers were created. ``cleanup`` is blocking on purpose: it has to + # complete *before* ``quit`` can take the event loop down, and waiting + # for it also proves the worker has stopped touching the controller. + QMetaObject.invokeMethod( + self._worker, "cleanup", Qt.ConnectionType.BlockingQueuedConnection + ) + self._thread.quit() + if not self._thread.wait(timeout_ms): + logger.warning( + "Poller thread did not stop within %sms; it will be left to the " + "process exit path", + timeout_ms, + ) diff --git a/apps/autocal/src/orisys_autocal/motion/service.py b/apps/autocal/src/orisys_autocal/motion/service.py new file mode 100644 index 0000000..8379c62 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/motion/service.py @@ -0,0 +1,670 @@ +"""Platform control logic: connection, bus init, axis commands. + +Pure Python and Qt-free, so the whole guard/state-machine layer is testable +without a GUI and without hardware. :mod:`~orisys_autocal.motion.poller` drives +it from a worker thread; the UI only ever calls these methods. + +Guards live here rather than in the UI on purpose. A motion command is refused +for a stated reason — bus not initialised, axis not idle, non-numeric input — +and :meth:`MotionService.jog` and friends return that reason as a +``MotionResult`` instead of silently doing nothing. +""" + +from __future__ import annotations + +import logging +import math +import time +import os +from typing import Callable, Iterable + +from orisys_app_shared.zmotion import ZAuxError + +from . import errors +from .models import ( + INIT_FAILED, + INIT_RUNNING, + INIT_TIMEOUT_S, + AxisParams, + AxisState, + AxisStatusBits, + BusStatus, + BusType, + ConnectionSpec, + MotionResult, + Snapshot, +) +from .transport import DemoTransport, ZmcTransport, NativeTransport +from .units import ( + DEFAULT_ACCEL, + DEFAULT_SPEED, + DEFAULT_UNITS, + PULSES_PER_MM, + safe_float, + user_units_to_mm, +) + +logger = logging.getLogger(__name__) + +#: The platform has five axes; the bus may report fewer if a drive is missing. +PLATFORM_AXES = 5 + +#: ``BUS_TYPE`` values written by the vendor ``.bas`` programs. +_BUS_TYPE_NAMES: dict[int, BusType] = {0: "ecat", 1: "rtex"} + + +class MotionService: + """The controller session: one connection, five axes, one state machine.""" + + def __init__( + self, + transport: ZmcTransport | None = None, + *, + axis_count: int = PLATFORM_AXES, + units_per_mm: float = DEFAULT_UNITS, + pulses_per_mm: float = PULSES_PER_MM, + language: str = "zh_CN", + ) -> None: + self._transport: ZmcTransport = transport or NativeTransport() + self.axis_count = int(axis_count) + self._units_per_mm = safe_float(units_per_mm, DEFAULT_UNITS) + self._pulses_per_mm = pulses_per_mm + self.language = language + + self._spec: ConnectionSpec | None = None + self._bus = BusStatus() + self._params: dict[int, AxisParams] = { + axis: AxisParams() for axis in range(self.axis_count) + } + self._bas_loaded = False + self._init_started_at: float | None = None + self._init_timeout = INIT_TIMEOUT_S + self._home_started: dict[int, float] = {} + self._home_done: dict[int, bool] = {} + + # ------------------------------------------------------------- properties + + @property + def transport(self) -> ZmcTransport: + return self._transport + + @property + def connected(self) -> bool: + return self._spec is not None + + @property + def spec(self) -> ConnectionSpec | None: + return self._spec + + @property + def bus(self) -> BusStatus: + return self._bus + + @property + def bas_loaded(self) -> bool: + return self._bas_loaded + + def params(self, axis: int) -> AxisParams: + return self._params.get(axis, AxisParams()) + + def set_language(self, language: str) -> None: + self.language = language + + def _msg(self, code: int) -> str: + return errors.describe_with_hint(code, language=self.language) + + def describe_error(self, exc: Exception) -> str: + """Localised text for a failed call, for the UI and the poller. + + A ``ZAuxError`` carries a controller code that :mod:`.errors` can + translate; anything else (a missing DLL, a socket error) is reported + as-is, which is where the "searched these paths" detail arrives. + """ + code = getattr(exc, "code", 0) + return self._msg(code) if code else str(exc) + + # ------------------------------------------------------------ connect/init + + def connect(self, spec: ConnectionSpec) -> MotionResult: + """Open the controller. + + A previous connection is closed first: switching transport type without + closing leaks the DLL handle, which the reference program does. + """ + if self._spec is not None: + self.disconnect() + try: + self._transport.open(spec) + except (ZAuxError, OSError, ValueError) as exc: + code = getattr(exc, "code", 0) + self._spec = None + message = self._msg(code) if code else str(exc) + logger.warning("Connect to %s failed: %s", spec.describe(), exc) + return MotionResult.failed(code, message) + + self._spec = spec + logger.info("Connected: %s via %s", spec.describe(), self._transport.source) + # A controller may already have a BAS program running from a previous + # session; adopt it rather than claiming the bus is uninitialised. + self._adopt_existing_bas() + return MotionResult.success(f"已连接 {spec.describe()}" if self._is_zh else f"Connected {spec.describe()}") + + @property + def _is_zh(self) -> bool: + return self.language == "zh_CN" + + def _adopt_existing_bas(self) -> None: + try: + bus_type = self._transport.user_var("BUS_TYPE") + except (ZAuxError, OSError): + self._bas_loaded = False + self._bus = BusStatus(bus_type="unknown", init_status=INIT_RUNNING) + return + self._bas_loaded = True + self._bus = BusStatus( + bus_type=_BUS_TYPE_NAMES.get(int(bus_type), "unknown"), + init_status=INIT_RUNNING, + bas_loaded=True, + ) + + def disconnect(self) -> None: + try: + self._transport.close() + except (ZAuxError, OSError): # pragma: no cover - close is best-effort + logger.exception("Closing the controller connection failed") + self._spec = None + self._bas_loaded = False + self._init_started_at = None + self._home_started.clear() + self._home_done.clear() + self._bus = BusStatus() + + def download_bas(self, filename: str, run_mode: int = 1) -> MotionResult: + """Download a ``.bas`` program to the controller and start its scan. + + ``run_mode=1`` writes to ROM, so the controller re-runs the program on + power-up as well; the vendor ``ECAT.bas`` delays 3 s, scans the slot, + then sets ``Bus_InitStatus``. The UI follows that through + :func:`poll`. + """ + if not self.connected: + return MotionResult.refused(self._text("未连接控制器", "Not connected")) + if not filename or not os.path.isfile(filename): + return MotionResult.refused( + self._text(f"文件ä¸�存在: {filename}", f"File not found: {filename}") + ) + try: + self._transport.bas_download(filename, run_mode) + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + self._bas_loaded = False + self._init_started_at = None + return MotionResult.failed(code, self._msg(code) if code else str(exc)) + + self._bas_loaded = True + self._init_started_at = time.monotonic() + self._bus = BusStatus( + bus_type=self._bus.bus_type, + init_status=INIT_RUNNING, + bas_path=filename, + bas_loaded=True, + init_started_at=self._init_started_at, + ) + # Read BUS_TYPE so the SDO/RTEX panels enable for the right family. + try: + bus_type = self._transport.user_var("BUS_TYPE") + self._bus = _replace_bus_type(self._bus, _BUS_TYPE_NAMES.get(int(bus_type), "unknown")) + except (ZAuxError, OSError): + pass + return MotionResult.success( + self._text( + "BAS 已下载,控制器将延时扫æ��总线", + "BAS downloaded; the controller will scan the bus after its delay", + ) + ) + + def reinit_bus(self) -> MotionResult: + """Re-run the BAS program's init task without re-downloading it.""" + if not self.connected: + return MotionResult.refused(self._text("未连接控制器", "Not connected")) + if not self._bas_loaded or not self._bus.bas_path: + return MotionResult.refused( + self._text( + "Bas文件未加载,请先下载 ECAT.bas 或 RTEX.bas", + "No BAS program loaded; download ECAT.bas or RTEX.bas first", + ) + ) + if self._bus.init_status == INIT_RUNNING and not self._bus.timed_out(time.monotonic()): + return MotionResult.refused( + self._text("总线正在åˆ�始化,请ç¨�候", "Bus initialisation is already running") + ) + + script = os.path.basename(self._bus.bas_path) + try: + self._transport.execute("STOPTASK 2") + self._transport.execute(f'RUN "{script}",4') + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + return MotionResult.failed(code, self._msg(code) if code else str(exc)) + + self._init_started_at = time.monotonic() + self._bus = BusStatus( + bus_type=self._bus.bus_type, + init_status=INIT_RUNNING, + bas_path=self._bus.bas_path, + bas_loaded=True, + init_started_at=self._init_started_at, + ) + return MotionResult.success( + self._text("正在é‡�æ–°åˆ�始化总线", "Re-initialising the bus") + ) + + def _text(self, zh: str, en: str) -> str: + return zh if self._is_zh else en + + # ----------------------------------------------------------------- polling + + def poll(self, axes: Iterable[int] | None = None) -> Snapshot: + """Read bus and axis state for one UI refresh. + + Never raises: a controller that has gone away produces a snapshot with + ``error`` set and the last known values, because a poll runs on a timer + and an exception there would either kill the timer or spam it. + """ + if not self.connected: + return Snapshot(connected=False, spec=self._spec, bus=self._bus) + + wanted = list(axes) if axes is not None else list(range(self.axis_count)) + error = "" + try: + self._refresh_bus() + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + error = self._msg(code) if code else str(exc) + logger.warning("Bus poll failed: %s", exc) + + states: list[AxisState] = [] + for axis in wanted: + try: + states.append(self._read_axis(axis)) + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + states.append( + AxisState( + axis=axis, + read_ok=False, + error=self._msg(code) if code else str(exc), + ) + ) + + return Snapshot( + connected=True, + spec=self._spec, + bus=self._bus, + axes=tuple(states), + error=error, + ) + + def _refresh_bus(self) -> None: + now = time.monotonic() + bus = self._bus + init_started = self._init_started_at + + # BUS_TYPE is present as soon as a BAS program has run at all. + bus_type = bus.bus_type + try: + raw_type = self._transport.user_var("BUS_TYPE") + bus_type = _BUS_TYPE_NAMES.get(int(raw_type), "unknown") + self._bas_loaded = True + except (ZAuxError, OSError): + # Between "downloaded" and the program's first instruction the + # globals do not exist yet, and they may never appear if the + # program failed to start. That is the timeout case, not a reason + # to forget that a download happened. + if init_started is None: + self._bas_loaded = False + + if not self._bas_loaded: + self._bus = BusStatus( + bus_type=bus_type, init_status=INIT_RUNNING, bas_loaded=False + ) + return + + init_status = bus.init_status + node_count = bus.node_count + total_axes = bus.total_axes + message = "" + + try: + init_status = int(self._transport.user_var("Bus_InitStatus")) + except (ZAuxError, OSError) as exc: + if init_started is not None and (now - init_started) > self._init_timeout: + # The vendor's own 25 s window has elapsed without the program + # publishing its globals: report a timeout, with the diagnostic + # the reference program prints, rather than raising on a timer. + init_status = INIT_FAILED + message = self._text( + "åˆ�始化超时:读ä¸�到 BUS_TYPE/Bus_InitStatus," + "请用 ZDevelop 查看 ?*ETHERCAT", + "Initialisation timed out: BUS_TYPE/Bus_InitStatus unreadable. " + "Check ?*ETHERCAT in ZDevelop.", + ) + logger.warning("Bus init timed out after %.0fs: %s", self._init_timeout, exc) + self._init_started_at = None + self._bas_loaded = False + else: + self._bus = BusStatus( + bus_type=bus_type, + init_status=INIT_RUNNING, + bas_path=bus.bas_path, + bas_loaded=True, + init_started_at=init_started, + ) + return + + if init_status != INIT_RUNNING: + self._init_started_at = None + try: + node_count = self._transport.bus_node_count(0) + total_axes = int(round(self._transport.user_var("Bus_TotalAxisnum"))) + except (ZAuxError, OSError): + pass + + self._bus = BusStatus( + bus_type=bus_type, + init_status=init_status, + node_count=node_count, + total_axes=total_axes, + bas_path=bus.bas_path, + bas_loaded=True, + init_started_at=self._init_started_at, + message=message, + ) + + def _read_axis(self, axis: int) -> AxisState: + transport = self._transport + status_raw = transport.get_axis_status(axis) + idle = transport.get_if_idle(axis) == -1 + status = AxisStatusBits(status_raw) + + # Track the homing edge the same way the reference program does: bit 6 + # set means homing is running, and the falling edge means ask the drive + # whether it actually completed. Between those two, report the last + # known answer rather than claiming homing either way. + home_status = 1 if self._home_done.get(axis, False) else 0 + if status.home_in_progress: + self._home_started[axis] = self._home_started.get(axis, time.monotonic()) + self._home_done[axis] = False + home_status = 0 + elif axis in self._home_started: + del self._home_started[axis] + try: + home_status = int(transport.bus_home_status(axis)) + except (ZAuxError, OSError): + home_status = 0 + self._home_done[axis] = home_status == 1 + + params = self.params(axis) + return AxisState( + axis=axis, + enabled=transport.get_axis_enable(axis) != 0, + atype=transport.get_atype(axis), + units=transport.get_units(axis), + speed=transport.get_speed(axis), + accel=transport.get_accel(axis), + dpos=transport.get_dpos(axis), + mpos=transport.get_mpos(axis), + status=status, + idle=idle, + home_status=home_status, + read_ok=True, + ) + + # -------------------------------------------------------------- axis setup + + def set_axis_params(self, axis: int, params: AxisParams) -> MotionResult: + """Apply units/speed/accel to the controller and remember them.""" + if not self.connected: + return MotionResult.refused(self._text("未连接控制器", "Not connected")) + if not self._known_axis(axis): + return MotionResult.refused(self._axis_range_message(axis)) + try: + self._transport.set_units(axis, params.units) + self._transport.set_speed(axis, params.speed) + self._transport.set_accel(axis, params.accel) + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + return MotionResult.failed(code, self._msg(code) if code else str(exc)) + self._params[axis] = params + return MotionResult.success() + + def set_axis_enable(self, axis: int, enabled: bool) -> MotionResult: + if not self.connected: + return MotionResult.refused(self._text("未连接控制器", "Not connected")) + if not self._known_axis(axis): + return MotionResult.refused(self._axis_range_message(axis)) + try: + self._transport.set_axis_enable(axis, enabled) + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + return MotionResult.failed(code, self._msg(code) if code else str(exc)) + return MotionResult.success( + self._text(f"è½´{axis} å·²{'使能' if enabled else '关闭'}", + f"Axis {axis} {'enabled' if enabled else 'disabled'}") + ) + + def clear_alarm(self, axis: int) -> MotionResult: + if not self.connected: + return MotionResult.refused(self._text("未连接控制器", "Not connected")) + if not self._known_axis(axis): + return MotionResult.refused(self._axis_range_message(axis)) + try: + self._transport.bus_drive_clear(axis, 0) + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + return MotionResult.failed(code, self._msg(code) if code else str(exc)) + return MotionResult.success(self._text("已清除报警", "Alarm cleared")) + + # ------------------------------------------------------------------ motion + + def _motion_guard(self, axis: int) -> MotionResult | None: + """Return the refusal for a motion command, or ``None`` if it may run.""" + if not self.connected: + return MotionResult.refused(self._text("未连接控制器", "Not connected")) + if not self._known_axis(axis): + return MotionResult.refused(self._axis_range_message(axis)) + if not self._bus.initialized: + return MotionResult.refused( + self._text( + "总线未åˆ�始化完æˆ�,ç¦�æ­¢è¿�动", + "Bus is not initialised; motion is refused", + ) + ) + return None + + def jog(self, axis: int, forward: bool) -> MotionResult: + refusal = self._motion_guard(axis) + if refusal is not None: + return refusal + try: + self._transport.jog(axis, forward) + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + return MotionResult.failed(code, self._msg(code) if code else str(exc)) + return MotionResult.success( + self._text("正转" if forward else "å��转", "Forward" if forward else "Reverse") + ) + + def move_abs(self, axis: int, target: float) -> MotionResult: + return self._move(axis, target, absolute=True) + + def move_rel(self, axis: int, distance: float) -> MotionResult: + return self._move(axis, distance, absolute=False) + + def _move(self, axis: int, value: float, *, absolute: bool) -> MotionResult: + refusal = self._motion_guard(axis) + if refusal is not None: + return refusal + if not isinstance(value, (int, float)) or not math.isfinite(float(value)): + return MotionResult.refused( + self._text("目标ä½�置请输入数字", "Enter a numeric position") + ) + try: + if absolute: + self._transport.move_abs(axis, float(value)) + else: + self._transport.move_rel(axis, float(value)) + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + return MotionResult.failed(code, self._msg(code) if code else str(exc)) + return MotionResult.success() + + def stop(self, axis: int) -> MotionResult: + """Stop one axis (decelerating stop, mode 2).""" + if not self.connected: + return MotionResult.refused(self._text("未连接控制器", "Not connected")) + if not self._known_axis(axis): + return MotionResult.refused(self._axis_range_message(axis)) + try: + self._transport.stop(axis, 2) + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + return MotionResult.failed(code, self._msg(code) if code else str(exc)) + return MotionResult.success(self._text("å·²å�œæ­¢", "Stopped")) + + def stop_all(self) -> list[MotionResult]: + """Stop every axis. Used before disconnecting.""" + return [self.stop(axis) for axis in range(self.axis_count)] + + def home(self, axis: int) -> MotionResult: + """Start driver homing (EtherCAT only, as the vendor example notes). + + Speeds and offset are written before the mode: the controller latches + them when homing starts, so setting them afterwards has no effect on + the move already in progress. + """ + refusal = self._motion_guard(axis) + if refusal is not None: + return refusal + if not self._bus.supports_sdo: + return MotionResult.refused( + self._text("rtexä¸�支æŒ�回零", "Homing is not supported over RTEX") + ) + params = self.params(axis) + if params.home_mode <= 0: + return MotionResult.refused( + self._text( + f"è½´{axis} 回零模å¼�未设置(0 ä¸�是有效模å¼�)", + f"Axis {axis} has no homing mode set (0 is not a valid mode)", + ) + ) + try: + self._transport.set_speed(axis, params.home_high) + self._transport.set_creep(axis, params.home_low) + self._transport.bus_set_datum_offset(axis, params.home_offset) + self._transport.bus_datum(axis, params.home_mode) + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + return MotionResult.failed(code, self._msg(code) if code else str(exc)) + self._home_started[axis] = time.monotonic() + self._home_done[axis] = False + return MotionResult.success(self._text("回零已å�¯åЍ", "Homing started")) + + def _known_axis(self, axis: int) -> bool: + return 0 <= int(axis) < self.axis_count + + def _axis_range_message(self, axis: int) -> str: + return self._text( + f"è½´å�·è¶…出范围 (0-{self.axis_count - 1})", + f"Axis out of range (0-{self.axis_count - 1})", + ) + + # ---------------------------------------------------------------- bus data + + def sdo_read( + self, node: int, index: int, subindex: int, data_type: int + ) -> tuple[MotionResult, int]: + """Read an EtherCAT object; returns the result and the value read.""" + refusal = self._bus_data_guard("ecat") + if refusal is not None: + return refusal, 0 + try: + value = self._transport.sdo_read(node, index, subindex, data_type) + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + return MotionResult.failed(code, self._msg(code) if code else str(exc)), 0 + return MotionResult.success(), int(value) + + def sdo_write( + self, node: int, index: int, subindex: int, data_type: int, value: int + ) -> MotionResult: + return self._bus_write( + "ecat", + lambda: self._transport.sdo_write(node, index, subindex, data_type, int(value)), + ) + + def rtex_read(self, axis: int, parameter: int) -> tuple[MotionResult, float]: + refusal = self._bus_data_guard("rtex") + if refusal is not None: + return refusal, 0.0 + try: + value = self._transport.rtex_read(axis, parameter) + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + return MotionResult.failed(code, self._msg(code) if code else str(exc)), 0.0 + return MotionResult.success(), float(value) + + def rtex_write(self, axis: int, parameter: int, value: float) -> MotionResult: + return self._bus_write( + "rtex", lambda: self._transport.rtex_write(axis, parameter, float(value)) + ) + + def _bus_data_guard(self, family: BusType) -> MotionResult | None: + if not self.connected: + return MotionResult.refused(self._text("未连接控制器", "Not connected")) + if self._bus.bus_type == "unknown": + return MotionResult.refused( + self._text( + "总线类型未知,请先åˆ�始化总线", + "Bus type is unknown; initialise the bus first", + ) + ) + if self._bus.bus_type != family: + other = "ETHERCAT" if family == "rtex" else "RTEX" + return MotionResult.refused( + self._text(f"é�ž{other}模å�—", f"This is not a {other} module") + ) + return None + + def _bus_write(self, family: BusType, action: Callable[[], None]) -> MotionResult: + refusal = self._bus_data_guard(family) + if refusal is not None: + return refusal + try: + action() + except (ZAuxError, OSError) as exc: + code = getattr(exc, "code", 0) + return MotionResult.failed(code, self._msg(code) if code else str(exc)) + return MotionResult.success() + + # ------------------------------------------------------------------ display + + def to_mm(self, axis: int, user_units: float) -> float: + """Displayed millimetres for a reading. Derived, never a measurement.""" + units = self.params(axis).units or self._units_per_mm + return user_units_to_mm( + user_units, units=units, pulses_per_mm=self._pulses_per_mm + ) + + +def _replace_bus_type(bus: BusStatus, bus_type: BusType) -> BusStatus: + return BusStatus( + bus_type=bus_type, + init_status=bus.init_status, + node_count=bus.node_count, + total_axes=bus.total_axes, + bas_path=bus.bas_path, + bas_loaded=bus.bas_loaded, + init_started_at=bus.init_started_at, + message=bus.message, + ) diff --git a/apps/autocal/src/orisys_autocal/motion/transport.py b/apps/autocal/src/orisys_autocal/motion/transport.py new file mode 100644 index 0000000..25d7dbc --- /dev/null +++ b/apps/autocal/src/orisys_autocal/motion/transport.py @@ -0,0 +1,467 @@ +"""The seam between the platform-control logic and the controller DLL. + +:class:`ZmcTransport` is what :mod:`~orisys_autocal.motion.service` talks to. +Contract: every method **raises** :class:`~orisys_app_shared.zmotion.ZAuxError` +when the controller returns non-zero, and returns plain Python values (never +ctypes objects) when it succeeds. That lets the service have one error path and +lets the tests inject a fake that fails on demand. + +Three implementations: + +* :class:`NativeTransport` — the real controller, via the vendored DLL. +* :class:`DemoTransport` — a self-consistent simulated platform, so the whole + UI can be exercised with no hardware and no DLL. +* ``FakeTransport`` in the app's test suite — the same protocol, driven by the + test rather than by a simulation. +""" + +from __future__ import annotations + +import math +import time +from typing import Protocol, runtime_checkable + +from orisys_app_shared.zmotion import ZAuxError, ZmotionClient + +from .errors import ERR_AUX_OFFSET +from .models import BusType, ConnectionSpec + + +@runtime_checkable +class ZmcTransport(Protocol): + """What the service needs from a controller connection.""" + + #: Human-readable source, e.g. the DLL directory or ``"demo"``. + source: str + + def open(self, spec: ConnectionSpec) -> None: ... + + def close(self) -> None: ... + + def search_eth(self) -> list[str]: ... + + def bas_download(self, filename: str, run_mode: int = 1) -> None: ... + + def execute(self, command: str) -> None: ... + + def user_var(self, name: str) -> float: ... + + def bus_node_count(self, slot: int = 0) -> int: ... + + def bus_home_status(self, axis: int) -> int: ... + + def bus_datum(self, axis: int, home_mode: int) -> None: ... + + def bus_set_datum_offset(self, axis: int, value: float) -> None: ... + + def bus_drive_clear(self, axis: int, mode: int = 0) -> None: ... + + def sdo_read( + self, node: int, index: int, subindex: int, data_type: int, slot: int = 0 + ) -> int: ... + + def sdo_write( + self, + node: int, + index: int, + subindex: int, + data_type: int, + value: int, + slot: int = 0, + ) -> None: ... + + def rtex_read(self, axis: int, parameter: int) -> float: ... + + def rtex_write(self, axis: int, parameter: int, value: float) -> None: ... + + def set_axis_enable(self, axis: int, enabled: bool) -> None: ... + + def set_units(self, axis: int, value: float) -> None: ... + + def get_units(self, axis: int) -> float: ... + + def set_speed(self, axis: int, value: float) -> None: ... + + def get_speed(self, axis: int) -> float: ... + + def set_accel(self, axis: int, value: float) -> None: ... + + def get_accel(self, axis: int) -> float: ... + + def set_creep(self, axis: int, value: float) -> None: ... + + def get_atype(self, axis: int) -> int: ... + + def get_axis_enable(self, axis: int) -> int: ... + + def get_dpos(self, axis: int) -> float: ... + + def get_mpos(self, axis: int) -> float: ... + + def get_axis_status(self, axis: int) -> int: ... + + def get_if_idle(self, axis: int) -> int: ... + + def jog(self, axis: int, forward: bool) -> None: ... + + def move_abs(self, axis: int, position: float) -> None: ... + + def move_rel(self, axis: int, distance: float) -> None: ... + + def stop(self, axis: int, mode: int = 2) -> None: ... + + +class NativeTransport: + """The real controller, through the vendored ZMotion runtime.""" + + def __init__(self, client: ZmotionClient | None = None) -> None: + self._client = client or ZmotionClient() + + @property + def client(self) -> ZmotionClient: + return self._client + + @property + def source(self) -> str: + return self._client.dll_dir or "zauxdll.dll (not loaded)" + + def open(self, spec: ConnectionSpec) -> None: + if spec.mode == "eth": + result = self._client.open_eth(spec.ip) + elif spec.mode == "com": + result = self._client.open_com(spec.com_id) + elif spec.mode == "pci": + result = self._client.open_pci(spec.card_num) + elif spec.mode == "local": + result = self._client.fast_open(5, spec.local_string, 1000) + else: + raise ValueError(f"{spec.mode!r} is not a native connection mode") + result.raise_for_status(f"open {spec.describe()}") + + def close(self) -> None: + self._client.close() + + def search_eth(self) -> list[str]: + value = self._client.search_eth() + # A scan that finds nothing is not an error: the reference program + # lists the configured defaults and moves on. + if not value.ok: + return [] + return [item for item in str(value.value).split() if item] + + def bas_download(self, filename: str, run_mode: int = 1) -> None: + self._client.bas_download(filename, run_mode).raise_for_status("BAS download") + + def execute(self, command: str) -> None: + self._client.execute(command).raise_for_status(command) + + def user_var(self, name: str) -> float: + return float(self._client.get_user_var(name).raise_for_status(name).value) + + def bus_node_count(self, slot: int = 0) -> int: + return int(self._client.bus_node_count(slot).raise_for_status().value) + + def bus_home_status(self, axis: int) -> int: + return int(self._client.bus_home_status(axis).raise_for_status().value) + + def bus_datum(self, axis: int, home_mode: int) -> None: + self._client.bus_datum(axis, home_mode).raise_for_status("datum") + + def bus_set_datum_offset(self, axis: int, value: float) -> None: + self._client.bus_set_datum_offset(axis, value).raise_for_status() + + def bus_drive_clear(self, axis: int, mode: int = 0) -> None: + self._client.bus_drive_clear(axis, mode).raise_for_status() + + def sdo_read( + self, node: int, index: int, subindex: int, data_type: int, slot: int = 0 + ) -> int: + return int( + self._client.sdo_read(slot, node, index, subindex, data_type) + .raise_for_status("SDO read") + .value + ) + + def sdo_write( + self, + node: int, + index: int, + subindex: int, + data_type: int, + value: int, + slot: int = 0, + ) -> None: + self._client.sdo_write(slot, node, index, subindex, data_type, value).raise_for_status( + "SDO write" + ) + + def rtex_read(self, axis: int, parameter: int) -> float: + return float(self._client.rtex_read(axis, parameter).raise_for_status().value) + + def rtex_write(self, axis: int, parameter: int, value: float) -> None: + self._client.rtex_write(axis, parameter, value).raise_for_status() + + def set_axis_enable(self, axis: int, enabled: bool) -> None: + self._client.set_axis_enable(axis, 1 if enabled else 0).raise_for_status() + + def set_units(self, axis: int, value: float) -> None: + self._client.set_units(axis, value).raise_for_status() + + def get_units(self, axis: int) -> float: + return float(self._client.get_units(axis).raise_for_status().value) + + def set_speed(self, axis: int, value: float) -> None: + self._client.set_speed(axis, value).raise_for_status() + + def get_speed(self, axis: int) -> float: + return float(self._client.get_speed(axis).raise_for_status().value) + + def set_accel(self, axis: int, value: float) -> None: + self._client.set_accel(axis, value).raise_for_status() + + def get_accel(self, axis: int) -> float: + return float(self._client.get_accel(axis).raise_for_status().value) + + def set_creep(self, axis: int, value: float) -> None: + self._client.set_creep(axis, value).raise_for_status() + + def get_atype(self, axis: int) -> int: + return int(self._client.get_atype(axis).raise_for_status().value) + + def get_axis_enable(self, axis: int) -> int: + return int(self._client.get_axis_enable(axis).raise_for_status().value) + + def get_dpos(self, axis: int) -> float: + return float(self._client.get_dpos(axis).raise_for_status().value) + + def get_mpos(self, axis: int) -> float: + return float(self._client.get_mpos(axis).raise_for_status().value) + + def get_axis_status(self, axis: int) -> int: + return int(self._client.get_axis_status(axis).raise_for_status().value) + + def get_if_idle(self, axis: int) -> int: + return int(self._client.get_if_idle(axis).raise_for_status().value) + + def jog(self, axis: int, forward: bool) -> None: + self._client.single_vmove(axis, 1 if forward else -1).raise_for_status() + + def move_abs(self, axis: int, position: float) -> None: + self._client.single_move_abs(axis, position).raise_for_status() + + def move_rel(self, axis: int, distance: float) -> None: + self._client.single_move(axis, distance).raise_for_status() + + def stop(self, axis: int, mode: int = 2) -> None: + self._client.single_cancel(axis, mode).raise_for_status() + + +class DemoTransport: + """A self-consistent simulated 5-axis platform. + + Not a mock: it keeps position, enable state and motion state so the UI's + polling, jogging and stop behaviour are all exercised without hardware. + ``BUS_TYPE`` reports EtherCAT so the SDO panel is live too. + """ + + #: Where jogging stops, in user units — keeps a demo jog bounded. + TRAVEL_LIMIT = 5000.0 + + def __init__(self, *, axes: int = 5) -> None: + self._axes = int(axes) + self._enabled = {axis: True for axis in range(self._axes)} + self._units = {axis: 10.0 for axis in range(self._axes)} + self._speed = {axis: 1000.0 for axis in range(self._axes)} + self._accel = {axis: 10000.0 for axis in range(self._axes)} + self._dpos = {axis: 0.0 for axis in range(self._axes)} + self._moving_until = {axis: 0.0 for axis in range(self._axes)} + self._homing_until = {axis: 0.0 for axis in range(self._axes)} + self._home_done = {axis: False for axis in range(self._axes)} + self._bas_loaded = False + self._init_started: float | None = None + self._opened = False + self._sdo: dict[tuple[int, int, int], int] = {} + + @property + def source(self) -> str: + return "demo (simulated platform, no hardware)" + + def open(self, spec: ConnectionSpec) -> None: + if spec.mode != "demo": + raise ZAuxError(ERR_DEMO_UNSUPPORTED, "DemoTransport only serves demo mode") + self._opened = True + + def close(self) -> None: + self._opened = False + + def search_eth(self) -> list[str]: + return ["127.0.0.1"] + + def bas_download(self, filename: str, run_mode: int = 1) -> None: + if not self._opened: + raise ZAuxError(ERR_NOT_OPEN, "not connected") + self._bas_loaded = True + # ECAT.bas delays 3 s before scanning; mimic the delay so the UI's + # "initialising" state is visible rather than instantaneous. + self._init_started = time.monotonic() + DEMO_INIT_DELAY_S + + def execute(self, command: str) -> None: + if not self._opened: + raise ZAuxError(ERR_NOT_OPEN, "not connected") + if "RUN" in command.upper() and self._bas_loaded: + self._init_started = time.monotonic() + DEMO_INIT_DELAY_S + + def user_var(self, name: str) -> float: + if not self._bas_loaded: + # Mirrors the real controller: unknown/absent variable. + raise ZAuxError(ERR_AUX_OFFSET + 2, f"{name} is not defined") + if name == "BUS_TYPE": + return 0.0 + if name == "Bus_TotalAxisnum": + return float(self._axes) + if name == "Bus_InitStatus": + if self._init_started is not None and time.monotonic() >= self._init_started: + return 1.0 + return -1.0 + raise ZAuxError(ERR_AUX_OFFSET + 2, f"{name} is not defined") + + def _require_axis(self, axis: int) -> None: + if not (0 <= axis < self._axes): + raise ZAuxError(ERR_AUX_OFFSET + 2, f"axis {axis} does not exist") + + def bus_node_count(self, slot: int = 0) -> int: + return self._axes + + def bus_home_status(self, axis: int) -> int: + self._require_axis(axis) + return 1 if self._home_done[axis] else 0 + + def bus_datum(self, axis: int, home_mode: int) -> None: + self._require_axis(axis) + self._homing_until[axis] = time.monotonic() + DEMO_HOME_S + + def bus_set_datum_offset(self, axis: int, value: float) -> None: + self._require_axis(axis) + + def bus_drive_clear(self, axis: int, mode: int = 0) -> None: + self._require_axis(axis) + + def sdo_read( + self, node: int, index: int, subindex: int, data_type: int, slot: int = 0 + ) -> int: + return self._sdo.get((node, index, subindex), 0) + + def sdo_write( + self, + node: int, + index: int, + subindex: int, + data_type: int, + value: int, + slot: int = 0, + ) -> None: + self._sdo[(node, index, subindex)] = int(value) + + def rtex_read(self, axis: int, parameter: int) -> float: + raise ZAuxError(ERR_AUX_OFFSET + 3, "RTEX is not available in demo mode") + + def rtex_write(self, axis: int, parameter: int, value: float) -> None: + raise ZAuxError(ERR_AUX_OFFSET + 3, "RTEX is not available in demo mode") + + def set_axis_enable(self, axis: int, enabled: bool) -> None: + self._require_axis(axis) + self._enabled[axis] = bool(enabled) + + def set_units(self, axis: int, value: float) -> None: + self._require_axis(axis) + self._units[axis] = float(value) + + def get_units(self, axis: int) -> float: + self._require_axis(axis) + return self._units[axis] + + def set_speed(self, axis: int, value: float) -> None: + self._require_axis(axis) + self._speed[axis] = float(value) + + def get_speed(self, axis: int) -> float: + self._require_axis(axis) + return self._speed[axis] + + def set_accel(self, axis: int, value: float) -> None: + self._require_axis(axis) + self._accel[axis] = float(value) + + def get_accel(self, axis: int) -> float: + self._require_axis(axis) + return self._accel[axis] + + def set_creep(self, axis: int, value: float) -> None: + self._require_axis(axis) + + def get_atype(self, axis: int) -> int: + self._require_axis(axis) + return 65 # position mode, as ECAT.bas configures + + def get_axis_enable(self, axis: int) -> int: + self._require_axis(axis) + return 1 if self._enabled[axis] else 0 + + def get_dpos(self, axis: int) -> float: + self._require_axis(axis) + return self._dpos[axis] + + def get_mpos(self, axis: int) -> float: + self._require_axis(axis) + # Follow the command position with a small lag while moving. + return self._dpos[axis] * 0.999 + + def get_axis_status(self, axis: int) -> int: + self._require_axis(axis) + status = 0 + if self._homing_until[axis] > time.monotonic(): + status |= 64 # homing in progress + if self._moving_until[axis] > time.monotonic(): + status |= 2 # moving + return status + + def get_if_idle(self, axis: int) -> int: + self._require_axis(axis) + moving = self._moving_until[axis] > time.monotonic() + homing = self._homing_until[axis] > time.monotonic() + return 0 if (moving or homing) else -1 + + def jog(self, axis: int, forward: bool) -> None: + self._require_axis(axis) + self._moving_until[axis] = time.monotonic() + DEMO_JOG_S + + def move_abs(self, axis: int, position: float) -> None: + self._require_axis(axis) + if not math.isfinite(position): + raise ZAuxError(ERR_AUX_OFFSET + 2, "position is not finite") + self._dpos[axis] = float(position) + self._moving_until[axis] = time.monotonic() + DEMO_MOVE_S + + def move_rel(self, axis: int, distance: float) -> None: + self._require_axis(axis) + self.move_abs(axis, self._dpos[axis] + float(distance)) + + def stop(self, axis: int, mode: int = 2) -> None: + self._require_axis(axis) + self._moving_until[axis] = 0.0 + self._homing_until[axis] = 0.0 + self._home_done[axis] = True + + def bus_type(self) -> BusType: + return "ecat" + + +#: Demo timing constants (seconds), chosen so a human sees each state. +DEMO_INIT_DELAY_S = 1.5 +DEMO_JOG_S = 1.0 +DEMO_MOVE_S = 0.8 +DEMO_HOME_S = 2.0 + +#: Demo-only failure codes, kept outside the documented vendor range on purpose. +ERR_NOT_OPEN = 30500 +ERR_DEMO_UNSUPPORTED = 30501 diff --git a/apps/autocal/src/orisys_autocal/motion/units.py b/apps/autocal/src/orisys_autocal/motion/units.py new file mode 100644 index 0000000..0cd7f77 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/motion/units.py @@ -0,0 +1,72 @@ +"""User-unit to millimetre conversion. + +The numbers come from bench measurement on the platform, transcribed from the +vendor reference program's comments:: + + # è½´0(X)/è½´1(Y) 实测: UNITS=10 æ—¶ 1000用户å�•ä½� = 2mm → æ¯�毫米 5000 脉冲 + # 当é‡�5 → 1000å�•ä½�=1mm,ä¸�是100å�•ä½�=1mm + +so ``PULSES_PER_MM = 5000`` is the calibration constant and ``UNITS = 10`` is +the pulse equivalent the platform is configured with. The displayed millimetre +value is therefore *derived*, not measured: the UI labels it as such, and no +code path may treat it as a calibrated length. +""" + +from __future__ import annotations + +import math + +#: Measured: 5000 pulses per millimetre (see the module docstring). +PULSES_PER_MM = 5000.0 + +#: Pulse equivalent the platform is configured with by default. +DEFAULT_UNITS = 10.0 + +#: Move-parameter defaults, matching the reference program's UI initialisation. +DEFAULT_SPEED = 1000.0 +DEFAULT_ACCEL = 10000.0 + + +def safe_float(text: object, fallback: float) -> float: + """Parse ``text`` as a finite float, falling back on anything unusable. + + A blank, non-numeric, zero or non-finite entry must not silently become a + pulse equivalent of zero — on a motion controller that is a divide-by-zero + waiting to happen, or an unbounded move. + """ + try: + value = float(str(text).strip()) + except (TypeError, ValueError): + return fallback + if not math.isfinite(value) or value == 0.0: + return fallback + return value + + +def user_units_to_mm( + value: float, *, units: float = DEFAULT_UNITS, pulses_per_mm: float = PULSES_PER_MM +) -> float: + """Convert a controller user-unit value to millimetres. + + Args: + value: a ``DPOS``/``MPOS`` reading or move distance in user units. + units: pulse equivalent in force on the axis. + pulses_per_mm: the platform's calibration constant. + + Returns: + Millimetres. Not finite when the inputs are not usable, so a caller + that forgets to validate sees an obvious NaN rather than a plausible + wrong number. + """ + if pulses_per_mm <= 0: + return math.nan + return float(value) * float(units) / float(pulses_per_mm) + + +def mm_to_user_units( + value: float, *, units: float = DEFAULT_UNITS, pulses_per_mm: float = PULSES_PER_MM +) -> float: + """Inverse of :func:`user_units_to_mm` (for an mm-entered move).""" + if units <= 0: + return math.nan + return float(value) * float(pulses_per_mm) / float(units) diff --git a/apps/autocal/src/orisys_autocal/preferences.py b/apps/autocal/src/orisys_autocal/preferences.py new file mode 100644 index 0000000..a02da39 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/preferences.py @@ -0,0 +1,179 @@ +"""AutoCal's preference schema and storage. + +The shared store keys off whatever schema it is handed, so AutoCal gets its own +key set rather than inheriting the studio's camera/plot/3D keys. The values here +are the ones an operator would otherwise retype at every start-up. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from orisys_app_shared import prefs as _prefs +from orisys_app_shared.paths import preference_path + +from .motion.service import PLATFORM_AXES +from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS + +#: Connection modes, in the order the combo offers them. +CONNECTION_MODES = ("eth", "com", "pci", "local", "demo") + +#: Addresses the controller is normally reached on, tried first by a scan. +DEFAULT_CONTROLLER_IPS = ("192.168.0.11", "127.0.0.1") + +#: Grid modes the sequence profile offers (workflow P5). +SURFACE_MODES = ("flat", "curved") + +#: Point visiting orders (workflow P5). +POINT_ORDERS = ("row", "serpentine", "distance") + +#: The design target is a well-lit bench, so light is the default; dark stays +#: selectable for a dim room. +DEFAULT_THEME = "light" + + +def _default_sequence_profile() -> dict[str, Any]: + """The point-generation and run parameters of workflow P5. + + One profile is global for now; named, saved sequences come with the runner. + The numbers are starting points an operator confirms at the bench, not + measured values — in particular ``home_mode``-style unknowns are left at 0 so + nothing is silently inherited from another machine. + """ + return { + # --- grid shape + "mode": "flat", + "pitch_mm": 2.0, + "margin_mm": 1.0, + "radius_mm": 0.0, + # --- motion + "clear_height_mm": 5.0, + "approach_speed_mm_s": 2.0, + "press_speed_mm_s": 0.5, + "press_depth_mm": 0.2, + "dwell_s": 0.5, + # --- measurement + "settle_window_s": 0.5, + "repeats": 3, + # --- order + "order": "serpentine", + } + + +def _default_safety_limits() -> dict[str, float]: + """Workflow P0: the limits that must have a value before anything moves. + + ``0`` means "not set" for the ones where a real value is required, and the + app reports that at the P0 gate rather than treating it as unlimited. + """ + return { + "max_travel_mm": 100.0, + "max_descent_mm": 10.0, + "max_press_depth_mm": 1.0, + "follow_error_mm": 0.5, + #: How far the axis may descend looking for contact before the run is + #: aborted (workflow P7, no-contact abort). + "contact_search_mm": 3.0, + } + + +def _default_sensor_setup() -> dict[str, Any]: + """Workflow P3/P4: what is under test, what touches it, and the transform. + + ``sensor_to_platform`` stays empty until the transform is established by + probing known features; the residual travels with it so a re-fixtured sensor + cannot silently reuse a stale transform. + """ + return { + "sensor_type": "box", + "config_ref": "", + "geometry_id": "", + #: Which axis presses, and the probe tip offset from the platform's + #: reference point. Empty offset means "not set", which blocks P5. + "press_axis": 2, + "probe_tip_offset_mm": [0.0, 0.0, 0.0], + "probe_tip_set": False, + #: 4x4 row-major sensor -> platform transform, plus how well it fitted. + "sensor_to_platform": [], + "transform_residual_mm": 0.0, + "transform_feature_count": 0, + } + + +def _default_axis_params() -> dict[str, dict[str, float | int]]: + """One parameter block per platform axis. + + ``home_mode`` starts at 0, which is **not** a valid CiA-402 homing mode: + the operator must choose one for the drive in front of them rather than + inherit a number from another machine. The reference program pre-filled 17, + which is a specific drive's mode, so it is deliberately not copied here. + """ + return { + str(axis): { + "units": DEFAULT_UNITS, + "speed": DEFAULT_SPEED, + "accel": DEFAULT_ACCEL, + "creep": 10.0, + "home_mode": 0, + "home_high": 50.0, + "home_low": 10.0, + "home_offset": 0.0, + } + for axis in range(PLATFORM_AXES) + } + + +def default_preferences() -> dict[str, Any]: + """AutoCal's default preference document.""" + return { + "language": "zh_CN", + "theme": DEFAULT_THEME, + "window_geometry": None, + "connection": { + "mode": "eth", + "ip": DEFAULT_CONTROLLER_IPS[0], + "com_id": 0, + "card_num": 0, + "local_string": "0", + }, + # Remembered so "download the BAS program" is one click after the first + # time, instead of re-picking the file every session. + "bas_path": "", + #: Displayed millimetres are derived from this; it is a bench-measured + #: constant, not a calibrated length (see motion/units.py). + "units_per_mm": DEFAULT_UNITS, + "state_poll_ms": 100, + "axis_params": _default_axis_params(), + #: {node, index, subindex, type} of the last SDO read/written, so the + #: operator can repeat a drive-setting check quickly. + "sdo_last": {"node": 0, "index": 24704, "subindex": 0, "type": 0}, + "rtex_last": {"axis": 0, "parameter": 0}, + "show_diagnostics": False, + # --- workflow blocks (see apps/autocal/docs/workflow.md) -------------- + #: P0: limits enforced from P2 onward. + "safety_limits": _default_safety_limits(), + #: P3/P4: sensor under test, probe tip, sensor<->platform transform. + "sensor_setup": _default_sensor_setup(), + #: P5: grid generation and run parameters. + "sequence_profile": _default_sequence_profile(), + } + + +def load_preferences() -> dict[str, Any]: + """Stored preferences with AutoCal's defaults filling the gaps.""" + prefs = _prefs.load_preferences(default_preferences()) + # A stored document from an older build may miss a newly added axis block. + axis_params = prefs.setdefault("axis_params", {}) + for axis, values in _default_axis_params().items(): + axis_params.setdefault(axis, values) + return prefs + + +def save_preferences(prefs: dict[str, Any]) -> Path: + return _prefs.save_preferences(prefs, default_preferences()) + + +def path() -> Path: + """Where AutoCal's ``preference.json`` lives (for the About box).""" + return preference_path() diff --git a/apps/autocal/src/orisys_autocal/ui/__init__.py b/apps/autocal/src/orisys_autocal/ui/__init__.py new file mode 100644 index 0000000..03e0fb1 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/__init__.py @@ -0,0 +1,17 @@ +"""AutoCal's windowed user interface.""" + +from .axis_panel import AxisPanel +from .brand_bar import BrandBar +from .bus_data_panel import BusDataPanel +from .bus_panel import BusPanel +from .connection_panel import ConnectionPanel +from .window import AutoCalMainWindow + +__all__ = [ + "AutoCalMainWindow", + "AxisPanel", + "BrandBar", + "BusDataPanel", + "BusPanel", + "ConnectionPanel", +] diff --git a/apps/autocal/src/orisys_autocal/ui/axis_panel.py b/apps/autocal/src/orisys_autocal/ui/axis_panel.py new file mode 100644 index 0000000..ff3a38e --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/axis_panel.py @@ -0,0 +1,611 @@ +"""Axis parameters, live state, and motion commands for all five axes. + +Two halves, both always visible because they answer different questions: + +* the **parameter form** edits one axis at a time (units, speed, accel, homing) + and applies it to the controller; +* the **state table** shows every axis at once — enable, position in user units + and millimetres, motion, homing — with that axis's actions in the last column. + +Keeping the whole platform in one table is a deliberate change from the vendor +program, which acts on whichever axis number happens to be typed in a box. On a +five-axis platform, acting on the wrong axis is the expensive mistake. +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QBrush, QColor +from PySide6.QtWidgets import ( + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from orisys_app_shared.i18n import t + +from ..motion import ( + DEFAULT_ACCEL, + DEFAULT_SPEED, + DEFAULT_UNITS, + AxisParams, + AxisState, + Snapshot, + safe_float, + user_units_to_mm, +) + +#: Data columns in the state table, in display order. +#: +#: Seven, not thirteen. Matching the full frame of a drive here made the middle +#: columns illegible at a real window width, and a table nobody can read protects +#: nobody. What does not fit is shown for the *selected* axis on the detail line +#: below the table — see :meth:`AxisPanel._detail_text`. +COLUMNS = ( + "axes.axis", + "axes.enable", + "axes.units", + "axes.speed", + "axes.dpos", + "axes.dpos_mm", + "axes.status", +) + +#: The actions column follows the data columns. +ACTIONS_COLUMN = len(COLUMNS) + +#: Row height in pixels. Must fit the per-row buttons: a QTableWidget does not +#: derive its row height from a cell widget, and the shared stylesheet gives a +#: button 28 px of minimum height plus 16 px of padding (44 px total) unless it +#: carries the ``rail`` property, which the theme defines as the compact variant. +ROW_HEIGHT = 36 + +#: How many axis rows the panel insists on showing without scrolling. Set to all +#: of them: on a five-axis platform a clipped fifth row reads as a missing drive. +#: This is also what makes the splitter give the panel the height it needs — Qt +#: distributes a splitter from the children's minimum sizes, so a smaller value +#: here means a scrollbar instead of five visible axes. +MIN_VISIBLE_ROWS = 5 + +#: Height of the small parameter fields. Pinned so the parameter form keeps to +#: two rows: the fields' natural height pushes that form to ~136 px, which costs +#: one and a half axis rows in the table below it. +FIELD_HEIGHT = 28 + +#: Column widths by index. With the project's thirteen columns the widths had to +#: shrink below the headers; seven can each be comfortable. +_COLUMN_WIDTHS = { + 0: 46, + 1: 96, + 2: 100, + 3: 100, + 4: 130, + 5: 130, + 6: 130, + ACTIONS_COLUMN: 230, +} + + +def parse_optional_float(text: str, fallback: float) -> float: + """A numeric field's value, or the fallback when it is blank/unusable.""" + if str(text).strip() == "": + return fallback + return safe_float(text, fallback) + + +class AxisPanel(QGroupBox): + """Parameters plus live state for the platform's axes.""" + + paramsApplied = Signal(int, object) + enableToggled = Signal(int, bool) + jogRequested = Signal(int, bool) + stopRequested = Signal(int) + stopAllRequested = Signal() + moveAbsRequested = Signal(int, float) + moveRelRequested = Signal(int, float) + homeRequested = Signal(int) + clearAlarmRequested = Signal(int) + + def __init__( + self, + axis_count: int, + params: dict[int, AxisParams] | None = None, + *, + units_per_mm: float = DEFAULT_UNITS, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName("groupBox") + self.axis_count = int(axis_count) + self._params = dict(params or {}) + self._units_per_mm = units_per_mm + self._connected = False + self._bus_ready = False + self._available_axes = 0 + self._selected_axis = 0 + self._snapshot = Snapshot() + self._rows: list[dict] = [] + + outer = QVBoxLayout(self) + outer.setContentsMargins(10, 8, 10, 10) + outer.setSpacing(6) + + outer.addWidget(self._build_params()) + outer.addWidget(self._build_target_row()) + # Stretch factor 1: the table is the only part of this panel that may + # grow, so every spare pixel goes to a further axis row rather than to + # padding around the parameter form. + outer.addWidget(self._build_table(), 1) + outer.addWidget(self._build_actions()) + + self.retranslate() + self._reread_params() + # Controls start disabled and a snapshot enables them. Qt's default for + # a new widget is enabled, and whether a poll happens to arrive before + # the window is shown is a race — so the safe state is set explicitly + # here rather than inferred from the first snapshot. + self._refresh_availability() + + # ------------------------------------------------------------------ widgets + + def _build_params(self) -> QWidget: + box = QWidget(self) + grid = QGridLayout(box) + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(8) + grid.setVerticalSpacing(4) + + def label(key: str) -> QLabel: + widget = QLabel(box) + widget.setObjectName("hintLabel") + return widget + + def edit(width: int) -> QLineEdit: + widget = QLineEdit(box) + widget.setFixedWidth(width) + widget.setFixedHeight(FIELD_HEIGHT) + return widget + + self.axis_label = label("axes.axis") + self.axis_combo = QWidget(box) + self.axis_index_label = QLabel("0", box) + self.axis_index_label.setObjectName("statusValue") + + self.units_label = label("axes.units") + self.units_edit = edit(80) + self.speed_label = label("axes.speed") + self.speed_edit = edit(80) + self.accel_label = label("axes.accel") + self.accel_edit = edit(90) + + self.home_mode_label = label("axes.home_mode") + self.home_mode_edit = edit(60) + self.home_high_label = label("axes.home_high") + self.home_high_edit = edit(60) + self.home_low_label = label("axes.home_low") + self.home_low_edit = edit(60) + self.home_offset_label = label("axes.home_offset") + self.home_offset_edit = edit(60) + + self.apply_button = QPushButton(box) + self.apply_button.setObjectName("primaryButton") + self.apply_button.setProperty("rail", True) + self.apply_button.setFixedHeight(FIELD_HEIGHT) + self.apply_button.clicked.connect(self._on_apply) + self.home_button = QPushButton(box) + self.home_button.setProperty("rail", True) + self.home_button.setFixedHeight(FIELD_HEIGHT) + self.home_button.clicked.connect(lambda: self.homeRequested.emit(self.selected_axis)) + self.clear_alarm_button = QPushButton(box) + self.clear_alarm_button.setProperty("rail", True) + self.clear_alarm_button.setFixedHeight(FIELD_HEIGHT) + self.clear_alarm_button.clicked.connect( + lambda: self.clearAlarmRequested.emit(self.selected_axis) + ) + + grid.addWidget(self.axis_label, 0, 0) + grid.addWidget(self.axis_index_label, 0, 1) + grid.addWidget(self.units_label, 0, 2) + grid.addWidget(self.units_edit, 0, 3) + grid.addWidget(self.speed_label, 0, 4) + grid.addWidget(self.speed_edit, 0, 5) + grid.addWidget(self.accel_label, 0, 6) + grid.addWidget(self.accel_edit, 0, 7) + + grid.addWidget(self.home_mode_label, 1, 0) + grid.addWidget(self.home_mode_edit, 1, 1) + grid.addWidget(self.home_high_label, 1, 2) + grid.addWidget(self.home_high_edit, 1, 3) + grid.addWidget(self.home_low_label, 1, 4) + grid.addWidget(self.home_low_edit, 1, 5) + grid.addWidget(self.home_offset_label, 1, 6) + grid.addWidget(self.home_offset_edit, 1, 7) + + grid.addWidget(self.apply_button, 2, 0, 1, 2) + grid.addWidget(self.home_button, 2, 2, 1, 2) + grid.addWidget(self.clear_alarm_button, 2, 4, 1, 2) + return box + + def _build_target_row(self) -> QWidget: + box = QWidget(self) + row = QHBoxLayout(box) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(8) + self.target_label = QLabel(box) + self.target_edit = QLineEdit(box) + self.target_edit.setFixedWidth(120) + self.move_abs_button = QPushButton(box) + self.move_abs_button.clicked.connect(self._on_move_abs) + self.move_rel_button = QPushButton(box) + self.move_rel_button.clicked.connect(self._on_move_rel) + self.mm_hint = QLabel(box) + self.mm_hint.setObjectName("hintLabel") + self.mm_hint.setWordWrap(True) + row.addWidget(self.target_label) + row.addWidget(self.target_edit) + row.addWidget(self.move_abs_button) + row.addWidget(self.move_rel_button) + row.addStretch(1) + row.addWidget(self.mm_hint) + return box + + def _build_table(self) -> QWidget: + self.table = QTableWidget(self.axis_count, len(COLUMNS) + 1, self) + self.table.verticalHeader().setVisible(False) + self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection) + self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.table.setAlternatingRowColors(True) + for column in range(len(COLUMNS)): + self.table.setColumnWidth(column, _COLUMN_WIDTHS.get(column, 84)) + self.table.setColumnWidth(ACTIONS_COLUMN, _COLUMN_WIDTHS[ACTIONS_COLUMN]) + # The per-row buttons live in a cell widget, and Qt does not size a row + # to its cell widgets. Without an explicit section height the row stays + # at the font height and the buttons are clipped. + self.table.verticalHeader().setDefaultSectionSize(ROW_HEIGHT) + # A minimum rather than the full five rows: demanding 5*ROW_HEIGHT plus a + # header forces the panel to be taller than its share of a 900 px window, + # and the splitter then clips the last drive — which reads as a missing + # axis. The table scrolls if it is ever squeezed below this. + self.table.setMinimumHeight(40 + ROW_HEIGHT * MIN_VISIBLE_ROWS) + self.table.cellClicked.connect(self._on_cell_clicked) + + for row in range(self.axis_count): + self._rows.append(self._build_row(row)) + return self.table + + def _build_row(self, row: int) -> dict: + for column in range(len(COLUMNS)): + item = QTableWidgetItem("") + if column >= 2: + item.setTextAlignment(Qt.AlignmentFlag.AlignHCenter) + self.table.setItem(row, column, item) + + enable_button = QPushButton(t("axes.enable_off"), self.table) + enable_button.setCheckable(True) + enable_button.setProperty("rail", True) + enable_button.setMinimumWidth(84) + enable_button.setFixedHeight(28) + enable_button.clicked.connect( + lambda checked, axis=row: self.enableToggled.emit(axis, bool(checked)) + ) + self.table.setCellWidget(row, 1, enable_button) + + controls = QWidget(self.table) + layout = QHBoxLayout(controls) + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(4) + + def make(key: str, slot) -> QPushButton: + button = QPushButton(t(key), controls) + # "rail" is the theme's compact button: without it the shared + # stylesheet forces 44 px, which no longer fits the row. + button.setProperty("rail", True) + button.setMinimumWidth(56) + button.setFixedHeight(28) + button.clicked.connect(lambda _checked=False, a=row: slot(a)) + layout.addWidget(button) + return button + + self.table.setCellWidget(row, ACTIONS_COLUMN, controls) + return { + "enable_button": enable_button, + "fwd_button": make("axes.jog_fwd", lambda axis: self.jogRequested.emit(axis, True)), + "rev_button": make("axes.jog_rev", lambda axis: self.jogRequested.emit(axis, False)), + "stop_button": make("axes.stop", self.stopRequested.emit), + } + + def _build_actions(self) -> QWidget: + box = QWidget(self) + column = QVBoxLayout(box) + column.setContentsMargins(0, 0, 0, 0) + column.setSpacing(4) + + self._detail_label = QLabel(t("axes.detail_none", axis=0), box) + self._detail_label.setObjectName("hintLabel") + self._detail_label.setWordWrap(True) + column.addWidget(self._detail_label) + + row = QHBoxLayout() + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(8) + self.stop_all_button = QPushButton(box) + self.stop_all_button.setObjectName("dangerButton") + self.stop_all_button.clicked.connect(self.stopAllRequested) + self.enabled_count_label = QLabel(box) + self.enabled_count_label.setObjectName("hintLabel") + row.addWidget(self.stop_all_button) + row.addStretch(1) + row.addWidget(self.enabled_count_label) + column.addLayout(row) + return box + + # ------------------------------------------------------------------- state + + @property + def selected_axis(self) -> int: + """The axis the parameter form applies to.""" + return self._selected_axis + + def set_selected_axis(self, axis: int) -> None: + self._selected_axis = max(0, min(int(axis), self.axis_count - 1)) + self.axis_index_label.setText(str(self._selected_axis)) + self._reread_params() + if hasattr(self, "_detail_label"): + self._detail_label.setText(self._detail_text()) + + def set_connected(self, connected: bool) -> None: + self._connected = bool(connected) + self._refresh_availability() + + def set_axis_count(self, count: int) -> None: + """Dim rows for axes the bus did not report (a missing drive).""" + self._available_axes = max(1, min(int(count), self.axis_count)) + self._refresh_availability() + self._recolour_rows() + + def _axis_present(self, row: int) -> bool: + return not self._available_axes or row < self._available_axes + + def _refresh_availability(self) -> None: + """Recompute every control's enabled state from connection + bus.""" + connected = self._connected + for widget in ( + self.units_edit, + self.speed_edit, + self.accel_edit, + self.home_mode_edit, + self.home_high_edit, + self.home_low_edit, + self.home_offset_edit, + self.target_edit, + self.apply_button, + self.home_button, + self.clear_alarm_button, + self.move_abs_button, + self.move_rel_button, + self.stop_all_button, + ): + widget.setEnabled(connected) + + for row, widgets in enumerate(self._rows): + present = self._axis_present(row) + widgets["enable_button"].setEnabled(connected and present) + widgets["stop_button"].setEnabled(connected and present) + # Jogging additionally requires an initialised bus. + for key in ("fwd_button", "rev_button"): + widgets[key].setEnabled(connected and present and self._bus_ready) + + def _recolour_rows(self) -> None: + """Dim the data cells of axes the bus did not report.""" + for row in range(self.axis_count): + present = self._axis_present(row) + for column in range(len(COLUMNS)): + item = self.table.item(row, column) + if item is not None: + item.setForeground(_row_brush(present)) + + def update_snapshot(self, snapshot: Snapshot) -> None: + """Render one poll: values, motion state, and the enable buttons.""" + self._connected = snapshot.connected + bus_axes = int(snapshot.bus.total_axes) + if bus_axes: + self._available_axes = max(1, min(bus_axes, self.axis_count)) + self._bus_ready = snapshot.bus.initialized + + for row in range(self.axis_count): + state = snapshot.axis(row) + if state is None: + continue + values = { + 0: str(row), + 1: t("axes.enable_on") if state.enabled else t("axes.enable_off"), + 2: _number(state.units, 3), + 3: _number(state.speed, 1), + 4: _number(state.dpos, 3), + 5: _millimetres(state.units, state.dpos), + 6: _status_text(state), + } + for column, text in values.items(): + item = self.table.item(row, column) + if item is not None: + item.setText(text) + button = self._rows[row]["enable_button"] + button.setChecked(bool(state.enabled)) + button.setText(t("axes.enable_on") if state.enabled else t("axes.enable_off")) + if state.status.home_in_progress: + button.setToolTip(t("axes.home_running")) + elif state.status.alarm: + button.setToolTip(t("action.failed")) + else: + button.setToolTip("") + + self._snapshot = snapshot + detail = self._detail_label + detail.setText(self._detail_text()) + enabled = sum( + 1 + for row in range(self.axis_count) + if (snapshot.axis(row) is not None and snapshot.axis(row).enabled) + ) + self.enabled_count_label.setText( + t("axes.enabled_count", on=enabled, total=self.axis_count) + ) + self._refresh_availability() + self._recolour_rows() + + def _detail_text(self) -> str: + """Everything about the selected axis that the seven columns cannot hold. + + The table is deliberately narrow enough to read; this line is where the + drive's full frame goes — axis type, acceleration, feedback position, the + raw status word, and homing. + """ + state = self._snapshot.axis(self._selected_axis) + if state is None: + return t("axes.detail_none", axis=self._selected_axis) + return t( + "axes.detail", + axis=self._selected_axis, + atype=state.atype, + accel=_number(state.accel, 1), + mpos=_number(state.mpos, 3), + mpos_mm=_millimetres(state.units, state.mpos), + status=state.status.raw, + home=_home_text(state), + ) + + # ---------------------------------------------------------------- actions + + def _on_cell_clicked(self, row: int, _column: int) -> None: + self.set_selected_axis(row) + + def _reread_params(self) -> None: + params = self._params.get(self._selected_axis, AxisParams()) + self.units_edit.setText(_number(params.units, 3)) + self.speed_edit.setText(_number(params.speed, 1)) + self.accel_edit.setText(_number(params.accel, 1)) + self.home_mode_edit.setText(str(int(params.home_mode))) + self.home_high_edit.setText(_number(params.home_high, 1)) + self.home_low_edit.setText(_number(params.home_low, 1)) + self.home_offset_edit.setText(_number(params.home_offset, 3)) + + def current_params(self) -> AxisParams: + """The form's values, with unusable entries replaced by defaults.""" + return AxisParams( + units=parse_optional_float(self.units_edit.text(), DEFAULT_UNITS), + speed=parse_optional_float(self.speed_edit.text(), DEFAULT_SPEED), + accel=parse_optional_float(self.accel_edit.text(), DEFAULT_ACCEL), + creep=parse_optional_float(self.home_low_edit.text(), 10.0), + home_mode=int(parse_optional_float(self.home_mode_edit.text(), 0)), + home_high=parse_optional_float(self.home_high_edit.text(), 50.0), + home_low=parse_optional_float(self.home_low_edit.text(), 10.0), + home_offset=parse_optional_float(self.home_offset_edit.text(), 0.0), + ) + + def stored_params(self) -> dict[int, AxisParams]: + """Every axis's parameters, for persistence.""" + return dict(self._params) + + def _on_apply(self) -> None: + params = self.current_params() + self._params[self._selected_axis] = params + self.paramsApplied.emit(self._selected_axis, params) + + def _on_move_abs(self) -> None: + value = self._target_value() + if value is not None: + self.moveAbsRequested.emit(self.selected_axis, value) + + def _on_move_rel(self) -> None: + value = self._target_value() + if value is not None: + self.moveRelRequested.emit(self.selected_axis, value) + + def _target_value(self) -> float | None: + text = self.target_edit.text().strip() + if text == "": + return None + try: + return float(text) + except ValueError: + return None + + # ------------------------------------------------------------- translations + + def retranslate(self) -> None: + self.setTitle(t("axes.title")) + self.axis_label.setText(t("axes.axis")) + self.units_label.setText(t("axes.units")) + self.speed_label.setText(t("axes.speed")) + self.accel_label.setText(t("axes.accel")) + self.home_mode_label.setText(t("axes.home_mode")) + self.home_high_label.setText(t("axes.home_high")) + self.home_low_label.setText(t("axes.home_low")) + self.home_offset_label.setText(t("axes.home_offset")) + self.apply_button.setText(t("axes.apply")) + self.home_button.setText(t("axes.home")) + self.clear_alarm_button.setText(t("axes.clear_alarm")) + self.target_label.setText(t("axes.target")) + self.move_abs_button.setText(t("axes.move_abs")) + self.move_rel_button.setText(t("axes.move_rel")) + self.stop_all_button.setText(t("axes.stop_all")) + self.mm_hint.setText(t("axes.mm_derived_tip")) + if hasattr(self, "_detail_label"): + self._detail_label.setText(self._detail_text()) + self.table.setHorizontalHeaderLabels( + [t(key) for key in COLUMNS] + [t("axes.controls")] + ) + for row in range(self.axis_count): + button = self._rows[row]["enable_button"] + button.setText(t("axes.enable_on") if button.isChecked() else t("axes.enable_off")) + self._rows[row]["fwd_button"].setText(t("axes.jog_fwd")) + self._rows[row]["rev_button"].setText(t("axes.jog_rev")) + self._rows[row]["stop_button"].setText(t("axes.stop")) + + +def _number(value: float, places: int) -> str: + try: + return f"{float(value):.{places}f}" + except (TypeError, ValueError): + return "—" + + +def _millimetres(units: float, user_units: float) -> str: + """The derived millimetre reading, or a dash when it cannot be derived.""" + if not units: + return "—" + millimetres = user_units_to_mm(user_units, units=units) + if millimetres != millimetres: # NaN + return "—" + return f"{millimetres:.3f}" + + +def _home_text(state: AxisState) -> str: + if state.status.home_in_progress: + return t("axes.home_running") + return t("axes.home_ok") if state.home_status == 1 else t("axes.home_pending") + + +def _status_text(state: AxisState) -> str: + """One column that says what an operator needs to know at a glance. + + Alarm outranks motion outranks homing, because that is the order in which + they matter when you are looking at the row for a second. + """ + if state.status.alarm: + return t("status.alarm_short") + if not state.idle: + return t("axes.moving") + return _home_text(state) + + +def _row_brush(present: bool) -> QBrush: + """Dim a row whose drive the bus did not report.""" + return QBrush(QColor("#d0d0d0" if present else "#8a8a8a")) diff --git a/apps/autocal/src/orisys_autocal/ui/axis_readout.py b/apps/autocal/src/orisys_autocal/ui/axis_readout.py new file mode 100644 index 0000000..5da18ca --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/axis_readout.py @@ -0,0 +1,170 @@ +"""The permanent axis readout rail. + +Read-only by design. Every per-axis *command* lives in the Axis-debug tab, so +there is exactly one place a move can be issued from — two places to jog from is +how an operator moves the wrong axis. What this rail adds is the thing the +operator needs while working in any other tab: whether each drive is enabled, +where it is, and whether it is moving. + +The three global actions are here rather than in the tab because they are the +ones you reach for in a hurry, and they must not require finding a tab first. +""" + +from __future__ import annotations + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, +) + +from orisys_app_shared.i18n import t +from orisys_app_shared.theme import load_colors + +from ..motion import Snapshot, user_units_to_mm + +#: Columns of the compact readout: axis, enable state, position. +_COLUMNS = 3 + +#: Tone -> colour token, as elsewhere. +_TONE_TOKENS = {"ok": "accent", "warn": "warn", "error": "danger", "muted": "text_muted"} + + +class AxisReadoutPanel(QGroupBox): + """Five axis rows plus the platform-wide actions.""" + + enableAllRequested = Signal(bool) + stopAllRequested = Signal() + clearAllRequested = Signal() + + def __init__(self, axis_count: int, theme: str = "dark", parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("groupBox") + self.axis_count = int(axis_count) + self._theme = theme + self._connected = False + self._snapshot = Snapshot() + self._rows: list[dict] = [] + + outer = QVBoxLayout(self) + outer.setContentsMargins(10, 8, 10, 10) + outer.setSpacing(6) + + grid = QGridLayout() + grid.setContentsMargins(0, 0, 0, 0) + grid.setHorizontalSpacing(8) + grid.setVerticalSpacing(4) + for row in range(self.axis_count): + grid.addWidget(self._build_row(row), row, 0) + outer.addLayout(grid) + + outer.addStretch(1) + + self.enable_all_button = QPushButton(self) + self.enable_all_button.clicked.connect(self._on_enable_all) + self.stop_all_button = QPushButton(self) + self.stop_all_button.setObjectName("dangerButton") + self.stop_all_button.clicked.connect(self.stopAllRequested) + self.clear_all_button = QPushButton(self) + self.clear_all_button.clicked.connect(self.clearAllRequested) + for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button): + button.setProperty("rail", True) + outer.addWidget(button) + + self.retranslate() + self.set_connected(False) + + def _build_row(self, axis: int) -> QWidget: + box = QWidget(self) + row = QHBoxLayout(box) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(6) + + index = QLabel(str(axis), box) + index.setObjectName("axisIndex") + index.setFixedWidth(16) + + state = QLabel("", box) + state.setObjectName("axisState") + state.setFixedWidth(46) + + position = QLabel("", box) + position.setObjectName("axisPosition") + + row.addWidget(index) + row.addWidget(state) + row.addWidget(position, 1) + self._rows.append({"state": state, "position": position}) + return box + + # ------------------------------------------------------------------- state + + def set_theme(self, theme: str) -> None: + self._theme = theme + self.update_snapshot(self._snapshot) + + def set_connected(self, connected: bool) -> None: + self._connected = bool(connected) + for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button): + button.setEnabled(self._connected) + + def update_snapshot(self, snapshot: Snapshot) -> None: + self._snapshot = snapshot + self.set_connected(snapshot.connected) + + for axis in range(self.axis_count): + state = snapshot.axis(axis) + widgets = self._rows[axis] + if state is None: + widgets["state"].setText(t("axes.enable_off")) + widgets["position"].setText("—") + widgets["state"].setStyleSheet(self._colour("muted")) + continue + + if state.status.alarm: + widgets["state"].setText(t("status.alarm_short")) + widgets["state"].setStyleSheet(self._colour("error")) + elif state.enabled: + widgets["state"].setText(t("axes.enable_on")) + widgets["state"].setStyleSheet(self._colour("ok")) + else: + widgets["state"].setText(t("axes.enable_off")) + widgets["state"].setStyleSheet(self._colour("muted")) + + millimetres = user_units_to_mm(state.dpos, units=state.units) if state.units else None + units_text = f"{state.dpos:.3f}" + if millimetres is not None and millimetres == millimetres: + units_text = t("axes.position_pair", value=units_text, mm=f"{millimetres:.3f}") + widgets["position"].setText(units_text) + widgets["position"].setToolTip( + t("axes.motion_word") + ": " + (t("axes.moving") if not state.idle else t("axes.idle")) + + (" · " + t("axes.home_ok") if state.home_status == 1 else "") + ) + + enabled_now = sum(1 for s in snapshot.axes if s.enabled) + self.enable_all_button.setText( + t("axes.disable_all") if enabled_now == self.axis_count else t("axes.enable_all") + ) + + def _colour(self, tone: str) -> str: + colour = load_colors(self._theme).get(_TONE_TOKENS.get(tone, "text_muted"), "#888888") + return f"color: {colour};" + + def _on_enable_all(self) -> None: + enabled_now = sum(1 for s in self._snapshot.axes if s.enabled) + # Toggle: if every drive is already on, this turns them all off. + self.enableAllRequested.emit(enabled_now != self.axis_count) + + # ------------------------------------------------------------- translations + + def retranslate(self) -> None: + self.setTitle(t("readout.title")) + self.stop_all_button.setText(t("axes.stop_all")) + self.clear_all_button.setText(t("axes.clear_alarm_all")) + # The toggle's label depends on current state, so re-derive it. + self.update_snapshot(self._snapshot) diff --git a/apps/autocal/src/orisys_autocal/ui/brand_bar.py b/apps/autocal/src/orisys_autocal/ui/brand_bar.py new file mode 100644 index 0000000..d79ac56 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/brand_bar.py @@ -0,0 +1,103 @@ +"""Top identity strip: brand, product name, and the connection state chip. + +Mirrors the studio's brand bar so the two products look like one family, but +carries only what this tool needs: who made it, what it is, and whether the +controller is reachable. +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget + +from orisys_app_shared.i18n import t +from orisys_app_shared.paths import logo_topbar_path +from orisys_app_shared.theme import load_colors + +#: Chip tone -> colour token name. +_TONE_TOKENS = { + "ok": "accent", + "warn": "warn", + "error": "danger", + "muted": "text_muted", +} + + +class BrandBar(QFrame): + """The identity strip, with a state chip that reports the connection.""" + + def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("brandBar") + self.setFixedHeight(40) + self._theme = theme + self._connected = False + # The chip's current content, kept so a theme or locale change can + # re-render the same state without the caller re-stating it. + self._chip_text = "" + self._chip_tone = "muted" + + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 4, 12, 4) + layout.setSpacing(10) + + self.logo = QLabel(self) + self.logo.setObjectName("brandLogo") + pixmap = QPixmap(str(logo_topbar_path())) + if not pixmap.isNull(): + self.logo.setPixmap( + pixmap.scaledToHeight(24, Qt.TransformationMode.SmoothTransformation) + ) + layout.addWidget(self.logo) + + self.name = QLabel(self) + self.name.setObjectName("brandName") + layout.addWidget(self.name) + + self.detail = QLabel(self) + self.detail.setObjectName("brandDetail") + layout.addWidget(self.detail) + + layout.addStretch(1) + + self.product = QLabel(self) + self.product.setObjectName("brandProduct") + layout.addWidget(self.product) + + self.state_chip = QLabel(self) + self.state_chip.setObjectName("stateChip") + layout.addWidget(self.state_chip) + + self.retranslate() + + # ----------------------------------------------------------------- content + + def retranslate(self) -> None: + """Refresh every label from the active locale.""" + self.name.setText(t("brand.name")) + self.detail.setText(t("brand.detail")) + self.product.setText(t("app.title")) + self.set_connected(self._connected) + + def set_theme(self, theme: str) -> None: + self._theme = theme + self.set_state_text(self._chip_text, self._chip_tone) + + def set_connected(self, connected: bool) -> None: + self._connected = bool(connected) + self.set_state_text( + t("connection.state_open") if connected else t("connection.state_closed"), + "ok" if connected else "muted", + ) + + def set_state_text(self, text: str, tone: str = "muted") -> None: + self._chip_text = text + self._chip_tone = tone + self.state_chip.setText(text) + token = _TONE_TOKENS.get(tone, "text_muted") + color = load_colors(self._theme).get(token, "#888888") + self.state_chip.setStyleSheet( + f"color: {color}; border: 1px solid {color}; " + "border-radius: 9px; padding: 1px 8px;" + ) diff --git a/apps/autocal/src/orisys_autocal/ui/bus_data_panel.py b/apps/autocal/src/orisys_autocal/ui/bus_data_panel.py new file mode 100644 index 0000000..fe6a05e --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/bus_data_panel.py @@ -0,0 +1,305 @@ +"""Raw bus exchange: EtherCAT object dictionary (SDO) and RTEX parameters. + +This is the drive-level panel. It is what a commissioning engineer reaches for +when a node behaves oddly: read an object, see what the drive actually has, write +one back. The controls are enabled strictly by the bus family the ``.bas`` +program reported, so an SDO write can never be aimed at an RTEX system (the +vendor reference guards this too, by checking ``BUS_TYPE`` before every call). +""" + +from __future__ import annotations + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QComboBox, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from orisys_app_shared.i18n import t + +#: Height of this panel's fields and buttons. Pinned for the same reason as the +#: axis panel's: an unconstrained QLineEdit is ~34 px, and four of them stacked +#: push this panel tall enough to steal an axis row from the panel above it. +FIELD_HEIGHT = 28 + +#: Width of the read/write buttons. A grid stretches a widget across the column +#: it spans, and these share a row with unconstrained columns, so without a cap +#: "读" renders as a 300 px slab. +ACTION_WIDTH = 96 + +#: Data types accepted by ``ZAux_BusCmd_SDORead``/``SDOWrite``, in combo order. +#: The DLL argument is the index + 1 (see ``sdo_data_type``). +SDO_TYPES = ( + ("int8", 1), + ("int16", 2), + ("int32", 3), + ("uint8", 4), + ("uint16", 5), + ("uint32", 6), +) + + +def sdo_data_type(index: int) -> int: + """Map a combo index to the DLL's data-type argument.""" + if 0 <= index < len(SDO_TYPES): + return SDO_TYPES[index][1] + return SDO_TYPES[0][1] + + +class BusDataPanel(QGroupBox): + """SDO and RTEX read/write.""" + + sdoReadRequested = Signal(int, int, int, int) + sdoWriteRequested = Signal(int, int, int, int, int) + rtexReadRequested = Signal(int, int) + rtexWriteRequested = Signal(int, int, float) + + def __init__( + self, + *, + sdo_last: dict | None = None, + rtex_last: dict | None = None, + axis_count: int = 5, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName("groupBox") + self._axis_count = int(axis_count) + sdo_last = sdo_last or {} + rtex_last = rtex_last or {} + + outer = QVBoxLayout(self) + outer.setContentsMargins(10, 8, 10, 10) + outer.setSpacing(8) + + outer.addWidget(self._build_sdo(sdo_last)) + outer.addWidget(self._build_rtex(rtex_last)) + + self.result_label = QLabel(self) + self.result_label.setObjectName("hintLabel") + self.result_label.setWordWrap(True) + outer.addWidget(self.result_label) + + self.retranslate() + # Disabled until a snapshot says otherwise: a read or write control that + # is briefly live on a disconnected panel invites a click that cannot + # work, and Qt's default for a new widget is enabled. + self.set_bus_type("unknown") + self._refresh_readonly() + + # ------------------------------------------------------------------ widgets + + def _build_sdo(self, last: dict) -> QWidget: + self.ecat_box = QGroupBox(self) + grid = QGridLayout(self.ecat_box) + grid.setContentsMargins(10, 6, 10, 6) + grid.setHorizontalSpacing(8) + grid.setVerticalSpacing(4) + + def field(text: str, width: int) -> QLineEdit: + widget = QLineEdit(str(text), self.ecat_box) + widget.setFixedWidth(width) + widget.setFixedHeight(FIELD_HEIGHT) + return widget + + def button(text: str) -> QPushButton: + widget = QPushButton(text, self.ecat_box) + widget.setProperty("rail", True) + widget.setFixedHeight(FIELD_HEIGHT) + widget.setMaximumWidth(ACTION_WIDTH) + return widget + + self.node_label = QLabel(self.ecat_box) + self.node_edit = field(last.get("node", 0), 60) + self.index_label = QLabel(self.ecat_box) + self.index_edit = field(last.get("index", 24704), 80) + self.subindex_label = QLabel(self.ecat_box) + self.subindex_edit = field(last.get("subindex", 0), 50) + self.sdo_type_label = QLabel(self.ecat_box) + self.sdo_type_combo = QComboBox(self.ecat_box) + self.sdo_type_combo.setFixedHeight(FIELD_HEIGHT) + for name, _value in SDO_TYPES: + self.sdo_type_combo.addItem(name) + self.sdo_type_combo.setCurrentIndex(int(last.get("type", 0)) % len(SDO_TYPES)) + self.sdo_value_label = QLabel(self.ecat_box) + self.sdo_value_edit = field(0, 90) + self.btn_sdo_read = button("") + self.btn_sdo_read.clicked.connect(self._emit_sdo_read) + self.btn_sdo_write = button("") + self.btn_sdo_write.clicked.connect(self._emit_sdo_write) + + grid.addWidget(self.node_label, 0, 0) + grid.addWidget(self.node_edit, 0, 1) + grid.addWidget(self.index_label, 0, 2) + grid.addWidget(self.index_edit, 0, 3) + grid.addWidget(self.subindex_label, 0, 4) + grid.addWidget(self.subindex_edit, 0, 5) + grid.addWidget(self.sdo_type_label, 0, 6) + grid.addWidget(self.sdo_type_combo, 0, 7) + grid.addWidget(self.sdo_value_label, 0, 8) + grid.addWidget(self.sdo_value_edit, 0, 9) + grid.addWidget(self.btn_sdo_read, 1, 7) + grid.addWidget(self.btn_sdo_write, 1, 9) + grid.setColumnStretch(10, 1) + return self.ecat_box + + def _build_rtex(self, last: dict) -> QWidget: + self.rtex_box = QGroupBox(self) + grid = QGridLayout(self.rtex_box) + grid.setContentsMargins(10, 6, 10, 6) + grid.setHorizontalSpacing(8) + grid.setVerticalSpacing(4) + + def field(text: str, width: int) -> QLineEdit: + widget = QLineEdit(str(text), self.rtex_box) + widget.setFixedWidth(width) + widget.setFixedHeight(FIELD_HEIGHT) + return widget + + def button(text: str) -> QPushButton: + widget = QPushButton(text, self.rtex_box) + widget.setProperty("rail", True) + widget.setFixedHeight(FIELD_HEIGHT) + return widget + + self.rtex_axis_label = QLabel(self.rtex_box) + self.rtex_axis_edit = field(last.get("axis", 0), 50) + self.rtex_parameter_label = QLabel(self.rtex_box) + self.rtex_parameter_edit = field(last.get("parameter", 0), 90) + self.rtex_value_label = QLabel(self.rtex_box) + self.rtex_value_edit = field(0, 90) + self.btn_rtex_read = button("") + self.btn_rtex_read.clicked.connect(self._emit_rtex_read) + self.btn_rtex_write = button("") + self.btn_rtex_write.clicked.connect(self._emit_rtex_write) + + grid.addWidget(self.rtex_axis_label, 0, 0) + grid.addWidget(self.rtex_axis_edit, 0, 1) + grid.addWidget(self.rtex_parameter_label, 0, 2) + grid.addWidget(self.rtex_parameter_edit, 0, 3) + grid.addWidget(self.rtex_value_label, 0, 4) + grid.addWidget(self.rtex_value_edit, 0, 5) + grid.addWidget(self.btn_rtex_read, 0, 6) + grid.addWidget(self.btn_rtex_write, 0, 7) + grid.setColumnStretch(8, 1) + return self.rtex_box + + # ------------------------------------------------------------------- state + + def set_bus_type(self, bus_type: str) -> None: + """Enable only the panel matching the bus the controller reported.""" + self._bus_type = bus_type + supports_sdo = bus_type == "ecat" + supports_rtex = bus_type == "rtex" + self.ecat_box.setEnabled(supports_sdo) + self.rtex_box.setEnabled(supports_rtex) + self._refresh_readonly() + + _bus_type = "unknown" + + def set_connected(self, connected: bool) -> None: + self._connected = bool(connected) + self._refresh_readonly() + + _connected = False + + def _refresh_readonly(self) -> None: + # Re-applying the bus type is what actually gates the boxes; a + # disconnected session disables both regardless of family. + if not self._connected: + self.ecat_box.setEnabled(False) + self.rtex_box.setEnabled(False) + return + self.ecat_box.setEnabled(self._bus_type == "ecat") + self.rtex_box.setEnabled(self._bus_type == "rtex") + + def set_result(self, text: str) -> None: + self.result_label.setText(text) + + def sdo_request(self) -> tuple[int, int, int, int]: + """The SDO fields as the service expects them.""" + return ( + self._int(self.node_edit.text()), + self._int(self.index_edit.text()), + self._int(self.subindex_edit.text()), + sdo_data_type(self.sdo_type_combo.currentIndex()), + ) + + def rtex_request(self) -> tuple[int, int]: + return ( + self._int(self.rtex_axis_edit.text()), + self._int(self.rtex_parameter_edit.text()), + ) + + @staticmethod + def _int(text: str) -> int: + try: + return int(str(text).strip() or 0, 0) + except ValueError: + return 0 + + def remember_state(self) -> dict[str, dict]: + """The field values the preference file should keep.""" + node, index, subindex, data_type = self.sdo_request() + axis, parameter = self.rtex_request() + type_index = next( + (i for i, (_name, value) in enumerate(SDO_TYPES) if value == data_type), 0 + ) + return { + "sdo_last": { + "node": node, + "index": index, + "subindex": subindex, + "type": type_index, + }, + "rtex_last": {"axis": axis, "parameter": parameter}, + } + + # ----------------------------------------------------------------- actions + + def _emit_sdo_read(self) -> None: + self.sdoReadRequested.emit(*self.sdo_request()) + + def _emit_sdo_write(self) -> None: + node, index, subindex, data_type = self.sdo_request() + self.sdoWriteRequested.emit( + node, index, subindex, data_type, self._int(self.sdo_value_edit.text()) + ) + + def _emit_rtex_read(self) -> None: + self.rtexReadRequested.emit(*self.rtex_request()) + + def _emit_rtex_write(self) -> None: + axis, parameter = self.rtex_request() + try: + value = float(self.rtex_value_edit.text()) + except ValueError: + self.set_result(t("busdata.not_available")) + return + self.rtexWriteRequested.emit(axis, parameter, value) + + # ------------------------------------------------------------- translations + + def retranslate(self) -> None: + self.setTitle(t("busdata.title")) + self.ecat_box.setTitle(t("busdata.ecat")) + self.rtex_box.setTitle(t("busdata.rtex")) + self.node_label.setText(t("busdata.node")) + self.index_label.setText(t("busdata.index")) + self.subindex_label.setText(t("busdata.subindex")) + self.sdo_type_label.setText(t("busdata.type")) + self.sdo_value_label.setText(t("busdata.value")) + self.btn_sdo_read.setText(t("busdata.read")) + self.btn_sdo_write.setText(t("busdata.write")) + self.rtex_axis_label.setText(t("busdata.axis")) + self.rtex_parameter_label.setText(t("busdata.parameter")) + self.rtex_value_label.setText(t("busdata.value")) + self.btn_rtex_read.setText(t("busdata.read")) + self.btn_rtex_write.setText(t("busdata.write")) diff --git a/apps/autocal/src/orisys_autocal/ui/bus_panel.py b/apps/autocal/src/orisys_autocal/ui/bus_panel.py new file mode 100644 index 0000000..48ac018 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/bus_panel.py @@ -0,0 +1,174 @@ +"""Bus initialisation: pick a BAS program, run it, and read the result. + +The sequence is the vendor's, not ours: ``BasDown`` writes the program to ROM +and the controller runs it; the program delays, scans the slot, maps axes, and +finally publishes ``BUS_TYPE``, ``Bus_InitStatus``, ``Bus_TotalAxisnum`` and the +node count. This panel is a view onto that state machine plus the two buttons +that (re)start it. +""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from orisys_app_shared.i18n import t +from orisys_app_shared.paths import app_root + +from ..motion import INIT_FAILED, INIT_OK, INIT_RUNNING, BusStatus + +_STATE_KEYS = { + INIT_RUNNING: "bus.state_running", + INIT_OK: "bus.state_ok", + INIT_FAILED: "bus.state_failed", +} + +_STATE_TONES = { + "bus.state_running": "warn", + "bus.state_ok": "ok", + "bus.state_failed": "error", + "bus.state_timeout": "error", +} + +#: The vendor requires a non-Chinese path for a BAS project; default the picker +#: at the shipped copies so the operator starts somewhere legal. +DEFAULT_BAS_DIR_NAME = "bas" + + +def default_bas_dir() -> Path: + return app_root() / "config" / DEFAULT_BAS_DIR_NAME + + +class BusPanel(QGroupBox): + """BAS download, re-initialisation, and decoded bus state.""" + + chooseRequested = Signal() + downloadRequested = Signal() + reinitRequested = Signal() + + def __init__(self, bas_path: str = "", parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("groupBox") + self._bas_path = bas_path + self._state_text = t("bus.state_pending") + self._state_tone = "muted" + + outer = QVBoxLayout(self) + outer.setContentsMargins(10, 8, 10, 10) + outer.setSpacing(8) + + grid = QGridLayout() + grid.setHorizontalSpacing(8) + grid.setVerticalSpacing(6) + + self.path_label = QLabel(self) + self.path_edit = QLineEdit(self) + self.path_edit.setReadOnly(True) + self.path_edit.setText(bas_path) + self.choose_button = QPushButton(self) + self.choose_button.clicked.connect(self.chooseRequested) + grid.addWidget(self.path_label, 0, 0) + grid.addWidget(self.path_edit, 0, 1, 1, 2) + grid.addWidget(self.choose_button, 0, 3) + + self.state_label = QLabel(self) + self.state_value = QLabel(self) + self.state_value.setObjectName("statusValue") + grid.addWidget(self.state_label, 1, 0) + grid.addWidget(self.state_value, 1, 1, 1, 3) + + self.type_label = QLabel(self) + self.type_value = QLabel(self) + self.type_value.setObjectName("statusValue") + grid.addWidget(self.type_label, 2, 0) + grid.addWidget(self.type_value, 2, 1, 1, 3) + + self.nodes_label = QLabel(self) + self.nodes_value = QLabel(self) + self.nodes_value.setObjectName("statusValue") + self.axes_label = QLabel(self) + self.axes_value = QLabel(self) + self.axes_value.setObjectName("statusValue") + grid.addWidget(self.nodes_label, 3, 0) + grid.addWidget(self.nodes_value, 3, 1) + grid.addWidget(self.axes_label, 3, 2) + grid.addWidget(self.axes_value, 3, 3) + + outer.addLayout(grid) + + buttons = QHBoxLayout() + self.download_button = QPushButton(self) + self.download_button.setObjectName("primaryButton") + self.download_button.clicked.connect(self.downloadRequested) + self.reinit_button = QPushButton(self) + self.reinit_button.clicked.connect(self.reinitRequested) + buttons.addWidget(self.download_button) + buttons.addWidget(self.reinit_button) + buttons.addStretch(1) + outer.addLayout(buttons) + + self.message_label = QLabel(self) + self.message_label.setObjectName("hintLabel") + self.message_label.setWordWrap(True) + outer.addWidget(self.message_label) + + self.retranslate() + + # ------------------------------------------------------------------- state + + @property + def bas_path(self) -> str: + return self._bas_path + + def set_bas_path(self, path: str) -> None: + self._bas_path = path + self.path_edit.setText(path) + + def set_connected(self, connected: bool) -> None: + self.download_button.setEnabled(connected) + self.reinit_button.setEnabled(connected) + self.choose_button.setEnabled(True) + + def set_status(self, bus: BusStatus, *, now: float | None = None) -> None: + """Render the bus state, including the vendor's timeout case.""" + key = _STATE_KEYS.get(bus.init_status, "bus.state_pending") + if bus.init_status == INIT_FAILED and bus.message and "è¶…æ—¶" in bus.message: + key = "bus.state_timeout" + self._state_text = t(key) + self._state_tone = _STATE_TONES.get(key, "muted") + self.state_value.setText(self._state_text) + + type_keys = { + "ecat": "bus.type_ecat", + "rtex": "bus.type_rtex", + "unknown": "bus.type_unknown", + } + self.type_value.setText(t(type_keys.get(bus.bus_type, "bus.type_unknown"))) + self.nodes_value.setText(str(bus.node_count)) + self.axes_value.setText(str(bus.total_axes)) + self.message_label.setText(bus.message) + + # ------------------------------------------------------------- translations + + def retranslate(self) -> None: + self.setTitle(t("bus.title")) + self.path_label.setText(t("bus.bas_path")) + self.state_label.setText(t("bus.state")) + self.type_label.setText(t("bus.type")) + self.nodes_label.setText(t("bus.nodes")) + self.axes_label.setText(t("bus.axes")) + self.choose_button.setText(t("bus.choose")) + self.download_button.setText(t("bus.download")) + self.reinit_button.setText(t("bus.reinit")) + self.state_value.setText(self._state_text) diff --git a/apps/autocal/src/orisys_autocal/ui/connection_panel.py b/apps/autocal/src/orisys_autocal/ui/connection_panel.py new file mode 100644 index 0000000..31d4e3e --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/connection_panel.py @@ -0,0 +1,218 @@ +"""Connection controls: mode, address, scan, connect, and connection state.""" + +from __future__ import annotations + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QComboBox, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from orisys_app_shared.i18n import t + +from ..motion import ConnectionSpec +from ..preferences import CONNECTION_MODES, DEFAULT_CONTROLLER_IPS + +#: Modes that are wired up. The rest report "not available yet" rather than +#: pretending to connect: a serial/PCI path that silently does nothing is worse +#: than one that says it is not there. +_IMPLEMENTED_MODES = ("eth", "local", "demo") + +_MODE_LABEL_KEYS = { + "eth": "connection.mode_eth", + "com": "connection.mode_com", + "pci": "connection.mode_pci", + "local": "connection.mode_local", + "demo": "connection.mode_demo", +} + + +class ConnectionPanel(QGroupBox): + """Everything needed to reach (or leave) the controller.""" + + connectRequested = Signal(object) + disconnectRequested = Signal() + scanRequested = Signal() + + def __init__(self, spec: ConnectionSpec, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("groupBox") + self._spec = spec + + outer = QVBoxLayout(self) + outer.setContentsMargins(10, 8, 10, 10) + outer.setSpacing(8) + + grid = QGridLayout() + grid.setHorizontalSpacing(8) + grid.setVerticalSpacing(6) + + self.mode_label = QLabel(self) + self.mode_combo = QComboBox(self) + for mode in CONNECTION_MODES: + self.mode_combo.addItem("", mode) + self.mode_combo.currentIndexChanged.connect(self._on_mode_changed) + grid.addWidget(self.mode_label, 0, 0) + grid.addWidget(self.mode_combo, 0, 1, 1, 2) + + self.address_label = QLabel(self) + self.address_edit = QLineEdit(self) + self.address_edit.setMinimumWidth(150) + self.scan_button = QPushButton(self) + self.scan_button.clicked.connect(self.scanRequested) + grid.addWidget(self.address_label, 1, 0) + grid.addWidget(self.address_edit, 1, 1) + grid.addWidget(self.scan_button, 1, 2) + + self.ip_hint = QLabel(self) + self.ip_hint.setObjectName("hintLabel") + grid.addWidget(self.ip_hint, 2, 1, 1, 2) + + outer.addLayout(grid) + + buttons = QHBoxLayout() + self.open_button = QPushButton(self) + self.open_button.setObjectName("primaryButton") + self.open_button.clicked.connect(self._emit_connect) + self.close_button = QPushButton(self) + self.close_button.clicked.connect(self.disconnectRequested) + self.close_button.setEnabled(False) + buttons.addWidget(self.open_button) + buttons.addWidget(self.close_button) + buttons.addStretch(1) + outer.addLayout(buttons) + + self.status_label = QLabel(self) + self.status_label.setObjectName("statusValue") + self.status_label.setWordWrap(True) + outer.addWidget(self.status_label) + + self.source_label = QLabel(self) + self.source_label.setObjectName("hintLabel") + self.source_label.setWordWrap(True) + outer.addWidget(self.source_label) + + self.retranslate() + self.apply_spec(spec) + + # ------------------------------------------------------------------- state + + @property + def spec(self) -> ConnectionSpec: + """The spec the fields currently describe.""" + mode = self.mode_combo.currentData() or "eth" + if mode == "eth": + return ConnectionSpec(mode="eth", ip=self.address_edit.text().strip()) + if mode == "local": + return ConnectionSpec(mode="local", local_string=self.address_edit.text().strip() or "0") + if mode == "com": + return ConnectionSpec(mode="com", com_id=self._as_int(self.address_edit.text())) + if mode == "pci": + return ConnectionSpec(mode="pci", card_num=self._as_int(self.address_edit.text())) + return ConnectionSpec(mode="demo") + + @staticmethod + def _as_int(text: str) -> int: + try: + return int(str(text).strip() or 0) + except ValueError: + return 0 + + def apply_spec(self, spec: ConnectionSpec) -> None: + self._spec = spec + index = self.mode_combo.findData(spec.mode) + if index >= 0: + self.mode_combo.setCurrentIndex(index) + self._fill_address(spec) + self._on_mode_changed() + + def _fill_address(self, spec: ConnectionSpec) -> None: + if spec.mode == "eth": + self.address_edit.setText(spec.ip or DEFAULT_CONTROLLER_IPS[0]) + elif spec.mode == "com": + self.address_edit.setText(str(spec.com_id)) + elif spec.mode == "pci": + self.address_edit.setText(str(spec.card_num)) + elif spec.mode == "local": + self.address_edit.setText(spec.local_string or "0") + else: + self.address_edit.setText("") + + def set_ips(self, ips: list[str]) -> None: + """Offer scanned addresses for the Ethernet mode.""" + if self.mode_combo.currentData() != "eth" or not ips: + return + current = self.address_edit.text().strip() + if current and current not in ips: + return + self.ip_hint.setText(" ".join(ips[:4])) + + def set_connected(self, connected: bool, source: str = "") -> None: + self.open_button.setEnabled(not connected) + self.close_button.setEnabled(connected) + self.mode_combo.setEnabled(not connected) + self.address_edit.setEnabled(not connected) + self.status_label.setText( + t("connection.state_open") if connected else t("connection.state_closed") + ) + self.status_label.setObjectName("statusValueOk" if connected else "statusValue") + self.source_label.setText(source) + # Re-apply the stylesheet so the object-name change takes effect. + self.status_label.style().unpolish(self.status_label) + self.status_label.style().polish(self.status_label) + + def set_status(self, text: str) -> None: + self.status_label.setText(text) + + # ----------------------------------------------------------------- actions + + def _emit_connect(self) -> None: + mode = self.mode_combo.currentData() or "eth" + if mode not in _IMPLEMENTED_MODES: + self.set_status(t("connection.no_more", mode=t(_MODE_LABEL_KEYS[mode]))) + return + self.connectRequested.emit(self.spec) + + def _on_mode_changed(self) -> None: + mode = self.mode_combo.currentData() or "eth" + keys = { + "eth": ("connection.ip", True), + "com": ("connection.com", True), + "pci": ("connection.card", True), + "local": ("connection.local_string", True), + "demo": ("connection.mode", False), + } + label_key, editable = keys.get(mode, ("connection.ip", True)) + self.address_label.setText(t(label_key)) + self.address_edit.setEnabled(editable) + self.scan_button.setEnabled(mode == "eth") + self.ip_hint.setText("") + if mode == "com": + self.address_edit.setText(str(self._spec.com_id)) + elif mode == "pci": + self.address_edit.setText(str(self._spec.card_num)) + elif mode == "local": + self.address_edit.setText(self._spec.local_string or "0") + elif mode == "eth": + self.address_edit.setText(self._spec.ip or DEFAULT_CONTROLLER_IPS[0]) + else: + self.address_edit.setText("") + + # ------------------------------------------------------------- translations + + def retranslate(self) -> None: + self.setTitle(t("connection.title")) + self.mode_label.setText(t("connection.mode")) + for index, mode in enumerate(CONNECTION_MODES): + self.mode_combo.setItemText(index, t(_MODE_LABEL_KEYS[mode])) + self.scan_button.setText(t("connection.scan")) + self.open_button.setText(t("connection.open")) + self.close_button.setText(t("connection.close")) + self._on_mode_changed() diff --git a/apps/autocal/src/orisys_autocal/ui/sequence_panel.py b/apps/autocal/src/orisys_autocal/ui/sequence_panel.py new file mode 100644 index 0000000..1b093d5 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/sequence_panel.py @@ -0,0 +1,487 @@ +"""Test-sequence design: what points on the sensor get tested (workflow P5). + +Three zones, because point generation is three questions that must be answered +together: + +* **profile** — the shape and the motion: grid mode, pitch and margin, standoff + heights, approach and press speeds, press depth, dwell, settle window, + repeats, visiting order. Persisted, so a bench profile is entered once. + Laid out as two columns of six fields: one column of twelve wasted the vertical + space the point list needs, and the list is the part that grows. +* **preview** — the sensor surface with the generated points and the order they + will be visited. Reserved space for the 3D view; today it says so rather than + pretending. +* **validation + point table** — every generated point is checked three ways + (on the surface, inside travel, contact confirmable) and carries a run status, + so the design and the run progress live in the same list. The list is a + ``QTableWidget`` because it has to hold hundreds of points and scroll; the + previous fixed grid could only ever show three rows. + +Nothing here moves the platform yet. The controls that would generate a point +set are present and bound to the profile, but generation, simulation and the run +are later phases — and :meth:`SequencePanel.planned_steps` still returns an empty +list so a runner cannot start from a table that was never validated. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QComboBox, + QGridLayout, + QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from orisys_app_shared.i18n import t + +from ..preferences import POINT_ORDERS, SURFACE_MODES + +#: Profile fields, in form order: ``(key, locale_key, kind)``. +#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit). +PROFILE_FIELDS: tuple[tuple[str, str, str], ...] = ( + ("mode", "sequence.field_mode", "choice"), + ("pitch_mm", "sequence.field_pitch", "number"), + ("margin_mm", "sequence.field_margin", "number"), + ("radius_mm", "sequence.field_radius", "number"), + ("clear_height_mm", "sequence.field_clear_height", "number"), + ("approach_speed_mm_s", "sequence.field_approach_speed", "number"), + ("press_speed_mm_s", "sequence.field_press_speed", "number"), + ("press_depth_mm", "sequence.field_press_depth", "number"), + ("dwell_s", "sequence.field_dwell", "number"), + ("settle_window_s", "sequence.field_settle", "number"), + ("repeats", "sequence.field_repeats", "number"), + ("order", "sequence.field_order", "choice"), +) + +#: How many rows the two-column profile form has. +PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2 + +#: Point-table columns: ``(key, locale_key)``. +POINT_COLUMNS: tuple[tuple[str, str], ...] = ( + ("index", "sequence.col_index"), + ("x", "sequence.col_x"), + ("y", "sequence.col_y"), + ("z", "sequence.col_z"), + ("status", "sequence.col_status"), + ("sensitivity", "sequence.col_sensitivity"), + ("samples", "sequence.col_samples"), + ("actions", "sequence.col_actions"), +) + +#: Editability of the point table. Positions are *generated*, not typed: a typed +#: coordinate is one that nothing has checked against the sensor. +_EDITABLE_POINT_COLUMNS: tuple[str, ...] = () + +#: Column widths of the point table, by column index. +_POINT_COLUMN_WIDTHS = (50, 130, 130, 130, 120, 160, 90, 200) + +#: Reserved controls and the phase that will fill each one. +PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run", "save", "load") + +#: Which phase each pending control belongs to (see docs/workflow.md). +PENDING_PHASES: dict[str, str] = { + "generate": "P5", + "simulate": "P6", + "dry_run": "P6", + "save": "P8", + "load": "P8", +} + +#: The validation checks, with the locale key naming what each one proves. +VALIDATION_CHECKS: tuple[tuple[str, str], ...] = ( + ("on_surface", "sequence.check_surface"), + ("within_travel", "sequence.check_travel"), + ("contact_confirmable", "sequence.check_contact"), +) + +#: Placeholder rows, so the flow shows its shape. Marked as unmeasured: a row +#: with no status is a design, not a measurement. +_EXAMPLE_POINTS: tuple[tuple[float, float, float], ...] = ( + (0.0, 0.0, 12.4), + (2.0, 0.0, 12.4), + (4.0, 0.0, 12.4), +) + + +@dataclass(frozen=True) +class SequenceStep: + """One planned step, as the future runner will receive it. + + ``x``/``y``/``z`` are platform-frame user units (millimetres are shown beside + them), ``repeat`` is a count, and ``force_n`` is ``None`` until FT feedback + exists. ``sensor_xyz`` is the point's position in the sensor frame, computed + from the P4 transform — the thing that makes it checkable against the surface. + """ + + index: int + targets: dict[str, float] + sensor_xyz: tuple[float, float, float] | None = None + repeat: int = 1 + force_n: float | None = None + dwell_s: float = 0.0 + note: str = "" + + +class SequencePanel(QGroupBox): + """Workflow P5: profile, preview, validation and the point list.""" + + generateRequested = Signal() + simulateRequested = Signal() + dryRunRequested = Signal() + saveRequested = Signal() + loadRequested = Signal() + + def __init__(self, profile: dict[str, Any] | None = None, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("groupBox") + self._profile_fields: dict[str, QWidget] = {} + self._profile_labels: list[QLabel] = [] + self._choices: dict[str, QComboBox] = {} + self._pending_buttons: dict[str, QPushButton] = {} + self._check_rows: dict[str, QLabel] = {} + + outer = QVBoxLayout(self) + outer.setContentsMargins(10, 8, 10, 10) + outer.setSpacing(8) + + zones = QHBoxLayout() + zones.setSpacing(10) + zones.addWidget(self._build_profile(), 0) + zones.addWidget(self._build_preview(), 1) + zones.addWidget(self._build_validation(), 0) + outer.addLayout(zones, 0) + + # The point list is the part that grows, so it takes the vertical stretch + # and the zones above it get what they need. + outer.addWidget(self._build_point_table(), 1) + + self.set_profile(profile or {}) + self.retranslate() + + # ------------------------------------------------------------------ profile + + def _build_profile(self) -> QWidget: + box = QGroupBox(self) + box.setObjectName("groupBox") + grid = QGridLayout(box) + grid.setContentsMargins(10, 8, 10, 8) + grid.setHorizontalSpacing(10) + grid.setVerticalSpacing(4) + + # Two fields per row: label, field, label, field. Seeing the whole profile + # at once is what lets an operator sanity-check a pitch against a depth. + for index, (key, _locale_key, kind) in enumerate(PROFILE_FIELDS): + row, side = divmod(index, 2) + column = side * 2 + label = QLabel(box) + label.setObjectName("hintLabel") + grid.addWidget(label, row, column) + self._profile_labels.append(label) + + if kind == "choice": + field: QWidget = QComboBox(box) + field.setFixedHeight(28) + self._choices[key] = field # type: ignore[assignment] + else: + field = QLineEdit(box) + field.setFixedHeight(28) + field.setProperty("fieldKey", key) + grid.addWidget(field, row, column + 1) + self._profile_fields[key] = field + + grid.setColumnStretch(1, 1) + grid.setColumnStretch(3, 1) + self._profile_box = box + return box + + # ------------------------------------------------------------------ preview + + def _build_preview(self) -> QWidget: + box = QGroupBox(self) + box.setObjectName("groupBox") + column = QVBoxLayout(box) + column.setContentsMargins(10, 8, 10, 8) + column.setSpacing(6) + + self.preview_label = QLabel(box) + self.preview_label.setObjectName("hintLabel") + self.preview_label.setWordWrap(True) + self.preview_label.setMinimumHeight(120) + self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + column.addWidget(self.preview_label, 1) + + self.preview_hint = QLabel(box) + self.preview_hint.setObjectName("hintLabel") + self.preview_hint.setWordWrap(True) + column.addWidget(self.preview_hint) + self._preview_box = box + return box + + # --------------------------------------------------------------- validation + + def _build_validation(self) -> QWidget: + box = QGroupBox(self) + box.setObjectName("groupBox") + column = QVBoxLayout(box) + column.setContentsMargins(10, 8, 10, 8) + column.setSpacing(4) + + for key, _locale_key in VALIDATION_CHECKS: + row = QLabel(box) + row.setObjectName("statusValue") + row.setWordWrap(True) + column.addWidget(row) + self._check_rows[key] = row + + self.summary_label = QLabel(box) + self.summary_label.setObjectName("hintLabel") + self.summary_label.setWordWrap(True) + column.addWidget(self.summary_label) + column.addStretch(1) + + for key in PENDING_CONTROLS: + button = QPushButton(box) + button.setProperty("rail", True) + button.setFixedHeight(28) + column.addWidget(button) + self._pending_buttons[key] = button + + self._pending_buttons["generate"].clicked.connect(self.generateRequested) + self._pending_buttons["simulate"].clicked.connect(self.simulateRequested) + self._pending_buttons["dry_run"].clicked.connect(self.dryRunRequested) + self._pending_buttons["save"].clicked.connect(self.saveRequested) + self._pending_buttons["load"].clicked.connect(self.loadRequested) + # None of these can run: generation, simulation, the runner and the + # dataset writer belong to later phases (see docs/workflow.md). + for button in self._pending_buttons.values(): + button.setEnabled(False) + + self._validation_box = box + return box + + # -------------------------------------------------------------- point table + + def _build_point_table(self) -> QWidget: + box = QGroupBox(self) + box.setObjectName("groupBox") + column = QVBoxLayout(box) + column.setContentsMargins(10, 8, 10, 8) + column.setSpacing(6) + + self.table = QTableWidget(len(_EXAMPLE_POINTS), len(POINT_COLUMNS), box) + self.table.verticalHeader().setVisible(False) + self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection) + self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.table.setAlternatingRowColors(True) + for index, width in enumerate(_POINT_COLUMN_WIDTHS): + self.table.setColumnWidth(index, width) + self.table.horizontalHeader().setSectionResizeMode( + len(POINT_COLUMNS) - 1, QHeaderView.ResizeMode.Stretch + ) + # The list scrolls rather than clipping: a grid of fixed labels could + # only ever show the rows it was built with. + self.table.setMinimumHeight(120) + + for row, (x, y, z) in enumerate(_EXAMPLE_POINTS): + values = { + 0: str(row + 1), + 1: f"{x:.3f}", + 2: f"{y:.3f}", + 3: f"{z:.3f}", + 4: t("sequence.status_pending"), + 5: "—", + 6: "—", + 7: "", + } + for column_index, text in values.items(): + item = QTableWidgetItem(text) + if column_index >= 4: + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.table.setItem(row, column_index, item) + + column.addWidget(self.table, 1) + + self.point_hint = QLabel(box) + self.point_hint.setObjectName("hintLabel") + self.point_hint.setWordWrap(True) + column.addWidget(self.point_hint) + + self._table_box = box + return box + + # -------------------------------------------------------------------- model + + def profile(self) -> dict[str, Any]: + """The profile as typed, with numbers parsed where they are numbers.""" + values: dict[str, Any] = {} + for key, _locale_key, kind in PROFILE_FIELDS: + widget = self._profile_fields[key] + if kind == "choice": + values[key] = widget.currentData() or widget.currentText() + else: + values[key] = _as_number(widget.text(), 0.0) + values["repeats"] = int(max(1, values.get("repeats", 1))) + return values + + def set_profile(self, profile: dict[str, Any]) -> None: + """Load a stored profile; unknown or missing keys keep their default.""" + for key, _locale_key, kind in PROFILE_FIELDS: + if key not in profile: + continue + widget = self._profile_fields[key] + value = profile[key] + if kind == "choice": + index = widget.findData(value) + if index >= 0: + widget.setCurrentIndex(index) + else: + widget.setText(_format_number(value)) + + def profile_keys(self) -> list[str]: + return [key for key, _locale_key, _kind in PROFILE_FIELDS] + + def field(self, key: str) -> QWidget: + return self._profile_fields[key] + + def choice(self, key: str) -> QComboBox: + return self._choices[key] + + def pending_controls(self) -> list[str]: + return list(PENDING_CONTROLS) + + def pending_phases(self) -> dict[str, str]: + return dict(PENDING_PHASES) + + def validation_checks(self) -> list[str]: + return [key for key, _locale_key in VALIDATION_CHECKS] + + def check_row(self, key: str) -> QLabel: + return self._check_rows[key] + + def set_check(self, key: str, text: str, ok: bool = True) -> None: + """Report one validation result (used once generation exists).""" + row = self._check_rows[key] + row.setText(text) + row.setProperty("checkOk", ok) + + def column_keys(self) -> list[str]: + return [key for key, _locale_key in POINT_COLUMNS] + + def point_row(self, index: int) -> dict[str, str]: + return { + key: (self.table.item(index, column).text() if self.table.item(index, column) else "") + for column, (key, _locale_key) in enumerate(POINT_COLUMNS) + } + + def point_row_count(self) -> int: + return self.table.rowCount() + + def append_point(self, x: float, y: float, z: float, status: str = "") -> int: + """Add a generated point. Used by the generator once P5 exists.""" + row = self.table.rowCount() + self.table.insertRow(row) + values = { + 0: str(row + 1), + 1: f"{x:.3f}", + 2: f"{y:.3f}", + 3: f"{z:.3f}", + 4: status or t("sequence.status_pending"), + 5: "—", + 6: "—", + 7: "", + } + for column, text in values.items(): + item = QTableWidgetItem(text) + if column >= 4: + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.table.setItem(row, column, item) + return row + + def planned_steps(self) -> list[SequenceStep]: + """The steps the table describes, for the future runner. + + Empty while the tab is a design surface: generation has not run, nothing + has been validated, and a runner must not start from a table that was + never checked — which is what the P5 gate exists to prevent. + """ + return [] + + # ------------------------------------------------------------- translations + + def retranslate(self) -> None: + self.setTitle(t("sequence.title")) + self._profile_box.setTitle(t("sequence.profile_title")) + self._preview_box.setTitle(t("sequence.preview_title")) + self._validation_box.setTitle(t("sequence.validation_title")) + self._table_box.setTitle(t("sequence.points_title")) + + for label, (_key, locale_key, _kind) in zip(self._profile_labels, PROFILE_FIELDS): + label.setText(t(locale_key)) + for key, combo in self._choices.items(): + _fill_choice(combo, key) + + self.preview_label.setText(t("sequence.preview_placeholder")) + self.preview_hint.setText(t("sequence.preview_hint")) + for key, _locale_key in VALIDATION_CHECKS: + self._check_rows[key].setText(t(f"sequence.check_{key}") + ": —") + self.summary_label.setText(t("sequence.validation_pending")) + for key in PENDING_CONTROLS: + self._pending_buttons[key].setText(t(f"sequence.btn_{key}")) + self._pending_buttons[key].setToolTip( + t("sequence.pending_tooltip", phase=PENDING_PHASES[key]) + ) + + self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS]) + for row in range(self.table.rowCount()): + item = self.table.item(row, 4) + if item is not None and item.text() in ("", t("sequence.status_pending")): + item.setText(t("sequence.status_pending")) + self.point_hint.setText(t("sequence.points_hint")) + + +def _fill_choice(combo: QComboBox, key: str) -> None: + """Populate a choice field from its option list, keeping the selection.""" + current = combo.currentData() + combo.blockSignals(True) + combo.clear() + if key == "mode": + for value in SURFACE_MODES: + combo.addItem(t(f"sequence.mode_{value}"), value) + elif key == "order": + for value in POINT_ORDERS: + combo.addItem(t(f"sequence.order_{value}"), value) + combo.blockSignals(False) + if current is not None: + index = combo.findData(current) + if index >= 0: + combo.setCurrentIndex(index) + + +def _as_number(text: str, fallback: float) -> float: + try: + value = float(str(text).strip()) + except (TypeError, ValueError): + return fallback + return value if value == value else fallback # reject NaN + + +def _format_number(value: Any) -> str: + try: + number = float(value) + except (TypeError, ValueError): + return str(value) + if number == int(number) and abs(number) < 1e6: + return str(int(number)) + return f"{number:g}" diff --git a/apps/autocal/src/orisys_autocal/ui/status_strip.py b/apps/autocal/src/orisys_autocal/ui/status_strip.py new file mode 100644 index 0000000..4c46e06 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/status_strip.py @@ -0,0 +1,424 @@ +"""The permanent motion status strip, and the run bar beneath it. + +Two rows on purpose: + +* :class:`StatusStrip` — connection, bus and last-action state. This is what must + not move: the operator needs to know whether the platform is live while + working in any tab, so it sits **above** the workspace tabs and switching tabs + cannot change what it says. It also carries the poll-health chip, because a + controller that stopped answering looks identical to one that is answering + normally unless something shows the age of the last reading. +* :class:`RunBar` — a run's phase, progress and controls. Separate from the strip + so a run cannot hide the platform state, and because the abort control must be + reachable from every tab rather than from inside the tab that started the run. + +Neither widget issues a controller call. The window wires the signals, so there +is still one place a command can originate. +""" + +from __future__ import annotations + +import time + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QProgressBar, + QWidget, +) + +from orisys_app_shared.i18n import t +from orisys_app_shared.theme import load_colors + +from ..motion import Snapshot + +#: Tone -> colour token. Matches the brand bar's chip vocabulary. +_TONE_TOKENS = { + "ok": "accent", + "warn": "warn", + "error": "danger", + "muted": "text_muted", +} + +#: Bus type -> locale key. +_BUS_KEYS = { + "ecat": "bus.type_ecat", + "rtex": "bus.type_rtex", + "unknown": "bus.type_unknown", +} + +#: Run phases, and the locale key that names each. +RUN_PHASES = { + "idle": "run.phase_idle", + "dry_run": "run.phase_dry_run", + "running": "run.phase_running", + "paused": "run.phase_paused", + "done": "run.phase_done", + "failed": "run.phase_failed", +} + +#: A snapshot older than this many poll intervals is stale, and then unhealthy. +_STALE_INTERVALS = 3.0 +_FAILED_INTERVALS = 10.0 + + +class _Chip(QLabel): + """A small toned label: the strip is read at a glance, not studied.""" + + def __init__(self, theme: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("statusChip") + self._theme = theme + self._tone = "muted" + self.set_tone("muted") + + def set_theme(self, theme: str) -> None: + self._theme = theme + self.set_tone(self._tone) + + def set_tone(self, tone: str) -> None: + self._tone = tone + colour = load_colors(self._theme).get(_TONE_TOKENS.get(tone, "text_muted"), "#888888") + self.setStyleSheet( + f"color: {colour}; border: 1px solid {colour}; " + "border-radius: 9px; padding: 1px 9px;" + ) + + +class StatusStrip(QFrame): + """Connection, bus and last-action state, always on screen.""" + + pauseRequested = Signal() + stopAllRequested = Signal() + + def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("statusStrip") + self.setFixedHeight(40) + self._theme = theme + self._snapshot = Snapshot() + self._last_action = "" + self._last_action_ok = True + self._last_snapshot_at: float | None = None + self._poll_interval_ms = 100 + + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 4, 10, 4) + layout.setSpacing(8) + + self.title = QLabel(self) + self.title.setObjectName("stripTitle") + layout.addWidget(self.title) + + self.chip_connection = _Chip(theme, self) + self.chip_bus = _Chip(theme, self) + self.chip_init = _Chip(theme, self) + self.chip_axes = _Chip(theme, self) + self.chip_health = _Chip(theme, self) + for chip in ( + self.chip_connection, + self.chip_bus, + self.chip_init, + self.chip_axes, + self.chip_health, + ): + layout.addWidget(chip) + + layout.addStretch(1) + + self.action_label = QLabel(self) + self.action_label.setObjectName("stripAction") + layout.addWidget(self.action_label) + + self.pause_button = QPushButton(self) + self.pause_button.setProperty("rail", True) + self.pause_button.setCheckable(True) + self.pause_button.clicked.connect(self.pauseRequested) + layout.addWidget(self.pause_button) + + self.stop_button = QPushButton(self) + self.stop_button.setObjectName("dangerButton") + self.stop_button.setProperty("rail", True) + self.stop_button.clicked.connect(self.stopAllRequested) + layout.addWidget(self.stop_button) + + self.retranslate() + self.set_snapshot(Snapshot()) + + # ------------------------------------------------------------------- state + + def set_theme(self, theme: str) -> None: + self._theme = theme + for chip in ( + self.chip_connection, + self.chip_bus, + self.chip_init, + self.chip_axes, + self.chip_health, + ): + chip.set_theme(theme) + self.chip_health.set_tone(self.chip_health._tone) + + def set_poll_interval_ms(self, interval_ms: int) -> None: + self._poll_interval_ms = max(1, int(interval_ms)) + + def set_snapshot(self, snapshot: Snapshot) -> None: + """Render one poll. Never raises: this runs on a timer.""" + self._snapshot = snapshot + self._last_snapshot_at = time.monotonic() + connected = snapshot.connected + + self.chip_connection.setText( + t("connection.state_open") if connected else t("connection.state_closed") + ) + self.chip_connection.set_tone("ok" if connected else "muted") + + self.chip_bus.setText(t(_BUS_KEYS.get(snapshot.bus.bus_type, "bus.type_unknown"))) + self.chip_bus.set_tone("ok" if snapshot.bus.bus_type != "unknown" else "muted") + + if not connected: + self.chip_init.setText(t("bus.state_pending")) + self.chip_init.set_tone("muted") + elif snapshot.bus.initialized: + self.chip_init.setText(t("bus.state_ok")) + self.chip_init.set_tone("ok") + elif snapshot.bus.init_status == 0: + text = ( + "bus.state_timeout" + if "è¶…æ—¶" in snapshot.bus.message or "timed out" in snapshot.bus.message + else "bus.state_failed" + ) + self.chip_init.setText(t(text)) + self.chip_init.set_tone("error") + else: + self.chip_init.setText(t("bus.state_running")) + self.chip_init.set_tone("warn") + + enabled = sum(1 for state in snapshot.axes if state.enabled) + total = len(snapshot.axes) or self._axis_total + self.chip_axes.setText(t("status.axes_enabled", on=enabled, total=total)) + self.chip_axes.set_tone("ok" if enabled else "muted") + + self.refresh_health() + self._refresh_action() + + #: Fallback axis total before the first snapshot describes the bus. + _axis_total = 5 + + def set_axis_total(self, total: int) -> None: + self._axis_total = max(1, int(total)) + + def refresh_health(self) -> None: + """Show how old the last reading is. + + A stalled poll is otherwise invisible: every other chip keeps displaying + its last value, so a controller that stopped answering looks exactly like + one that is answering normally. Called on each snapshot *and* on the + window's own timer, so the chip ages even when no snapshot arrives — + which is the case it exists to reveal. + """ + if self._last_snapshot_at is None: + self.chip_health.setText(t("status.health_none")) + self.chip_health.set_tone("muted") + return + if self.paused: + self.chip_health.setText(t("status.health_paused")) + self.chip_health.set_tone("muted") + return + + age_ms = (time.monotonic() - self._last_snapshot_at) * 1000.0 + intervals = age_ms / float(self._poll_interval_ms) + self.chip_health.setText(t("status.health_age", ms=int(age_ms))) + if intervals > _FAILED_INTERVALS: + self.chip_health.set_tone("error") + elif intervals > _STALE_INTERVALS: + self.chip_health.set_tone("warn") + else: + self.chip_health.set_tone("ok") + + def set_action_result(self, text: str, ok: bool = True) -> None: + """Record the outcome of the last command, and show it here. + + The status bar carries the same text; the strip is what stays visible + when a tab is switched, so the last refusal cannot scroll away unseen. + """ + self._last_action = str(text) + self._last_action_ok = bool(ok) + self._refresh_action() + + def _refresh_action(self) -> None: + self.action_label.setText(self._last_action or t("status.none")) + tone = "ok" if self._last_action_ok else "error" + colour = load_colors(self._theme).get(_TONE_TOKENS[tone], "#888888") + self.action_label.setStyleSheet(f"color: {colour};") + + @property + def paused(self) -> bool: + return self.pause_button.isChecked() + + def set_paused(self, paused: bool) -> None: + self.pause_button.setChecked(bool(paused)) + self._refresh_pause_text() + self.refresh_health() + + # ------------------------------------------------------------- translations + + def retranslate(self) -> None: + self.title.setText(t("status.motion")) + self._refresh_pause_text() + self.stop_button.setText(t("axes.stop_all")) + self.set_snapshot(self._snapshot) + self.refresh_health() + + def _refresh_pause_text(self) -> None: + self.pause_button.setText( + t("status.resume") if self.pause_button.isChecked() else t("status.pause") + ) + + +class RunBar(QFrame): + """A run's phase, progress and controls, visible from every tab.""" + + runRequested = Signal() + pauseRequested = Signal() + abortRequested = Signal() + + def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("runBar") + self.setFixedHeight(40) + self._theme = theme + self._phase = "idle" + self._total = 0 + self._completed = 0 + self._has_run = False + + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 4, 10, 4) + layout.setSpacing(8) + + self.title = QLabel(self) + self.title.setObjectName("stripTitle") + layout.addWidget(self.title) + + self.phase_label = QLabel(self) + self.phase_label.setObjectName("statusValue") + self.phase_label.setMinimumWidth(90) + layout.addWidget(self.phase_label) + + self.step_label = QLabel(self) + self.step_label.setObjectName("stripAction") + self.step_label.setMinimumWidth(140) + layout.addWidget(self.step_label) + + self.progress = QProgressBar(self) + self.progress.setTextVisible(False) + self.progress.setFixedHeight(12) + self.progress.setMinimumWidth(160) + layout.addWidget(self.progress, 1) + + self.time_label = QLabel(self) + self.time_label.setObjectName("stripAction") + self.time_label.setMinimumWidth(180) + layout.addWidget(self.time_label) + + self.run_button = QPushButton(self) + self.run_button.setObjectName("primaryButton") + self.pause_button = QPushButton(self) + self.abort_button = QPushButton(self) + self.abort_button.setObjectName("dangerButton") + for button in (self.run_button, self.pause_button, self.abort_button): + button.setProperty("rail", True) + button.setFixedHeight(26) + layout.addWidget(button) + + self.run_button.clicked.connect(self.runRequested) + self.pause_button.clicked.connect(self.pauseRequested) + self.abort_button.clicked.connect(self.abortRequested) + + self.retranslate() + self.set_run(False) + + # ------------------------------------------------------------------- state + + def set_theme(self, theme: str) -> None: + self._theme = theme + + def set_phase(self, phase: str) -> None: + if phase not in RUN_PHASES: + phase = "idle" + self._phase = phase + self.phase_label.setText(t(RUN_PHASES[phase])) + busy = phase in ("running", "paused") + tone = { + "running": "accent", + "paused": "warn", + "failed": "danger", + "done": "accent", + }.get(phase, "text_muted") + colour = load_colors(self._theme).get(tone, "#888888") + self.phase_label.setStyleSheet(f"color: {colour};") + self._refresh_buttons(busy) + + def set_progress(self, completed: int, total: int) -> None: + self._completed = max(0, int(completed)) + self._total = max(0, int(total)) + self.progress.setRange(0, self._total or 1) + self.progress.setValue(min(self._completed, self._total or 1)) + if self._total: + self.step_label.setText( + t("run.step_of", done=self._completed, total=self._total) + ) + else: + self.step_label.setText(t("run.no_steps")) + + def set_timing(self, elapsed_s: float, remaining_s: float | None) -> None: + """Elapsed, and the estimate. ``None`` means "not known yet".""" + if remaining_s is None: + self.time_label.setText(t("run.elapsed_only", elapsed=_duration(elapsed_s))) + else: + self.time_label.setText( + t("run.elapsed_remaining", elapsed=_duration(elapsed_s), remaining=_duration(remaining_s)) + ) + + def set_run(self, has_run: bool) -> None: + """Whether a run exists at all — i.e. whether abort must be reachable.""" + self._has_run = bool(has_run) + self._refresh_buttons(self._phase in ("running", "paused")) + + def _refresh_buttons(self, busy: bool) -> None: + # Abort is enabled as soon as a run exists: it is the one control that + # must never require finding a tab first. + self.abort_button.setEnabled(self._has_run) + self.pause_button.setEnabled(self._has_run and busy) + self.run_button.setEnabled(not busy) + + @property + def phase(self) -> str: + return self._phase + + # ------------------------------------------------------------- translations + + def retranslate(self) -> None: + self.title.setText(t("run.title")) + self.phase_label.setText(t(RUN_PHASES[self._phase])) + self.run_button.setText(t("run.run")) + self.pause_button.setText(t("run.pause")) + self.abort_button.setText(t("run.abort")) + if self._total: + self.step_label.setText( + t("run.step_of", done=self._completed, total=self._total) + ) + else: + self.step_label.setText(t("run.no_steps")) + self.time_label.setText(t("run.elapsed_only", elapsed=_duration(0.0))) + + +def _duration(seconds: float) -> str: + """``m:ss`` for a duration, which is how an operator reads a bench timer.""" + seconds = max(0.0, float(seconds)) + minutes, rest = divmod(int(seconds), 60) + return f"{minutes:d}:{rest:02d}" diff --git a/apps/autocal/src/orisys_autocal/ui/window.py b/apps/autocal/src/orisys_autocal/ui/window.py new file mode 100644 index 0000000..072b69b --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/window.py @@ -0,0 +1,913 @@ +"""The AutoCal main window: panels, menus, and the bridge to the poller. + +The window never touches the controller itself. Every controller call goes +through :class:`~orisys_autocal.motion.poller.MotionPoller`, which owns the DLL +handle on its own thread; results arrive as signals. That keeps the vendor +library's blocking calls (a ROM write, a bus scan) off the GUI thread and keeps +the ctypes handle single-threaded. +""" + +from __future__ import annotations + +import logging +import subprocess +import sys +from pathlib import Path + +from orisys_app_shared import API_VERSION, __version__ as shared_version +from orisys_app_shared.i18n import set_locale, t +from orisys_app_shared.paths import outputs_dir +from orisys_app_shared.theme import build_qss, load_colors + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QAction, QCloseEvent +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QMainWindow, + QMessageBox, + QScrollArea, + QSplitter, + QVBoxLayout, + QWidget, +) + +from .. import __version__ as app_version +from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot +from ..preferences import load_preferences, save_preferences +from .axis_panel import AxisPanel +from .axis_readout import AxisReadoutPanel +from .brand_bar import BrandBar +from .bus_data_panel import BusDataPanel +from .bus_panel import BusPanel, default_bas_dir +from .connection_panel import ConnectionPanel +from .sequence_panel import SequencePanel +from .status_strip import RunBar, StatusStrip +from .workspace import TABS, WorkspaceTabs + +logger = logging.getLogger(__name__) + +#: The main split: setup + readout rail, then the workspace. +#: +#: Two columns, not three. Two thin rails bracketing a workspace that has to hold +#: a twelve-field form, a 3D preview, a checks list and an eight-column table +#: cost 566 px of a 1920 screen — 29% — for chrome. Merging them into one rail +#: gives the workspace that 240 px back at every window size, which is the +#: difference between a workable sequence page and a cramped one at the minimum +#: window width. +MAIN_SPLITTER_SIZES = (330, 1560) + +#: Width below which the rail would clip its own controls. +MAIN_MINIMUM_WIDTH = 1440 + +#: Default and minimum window sizes. The default is the design target; the +#: minimum stays viable on a smaller bench monitor. +DEFAULT_WINDOW_SIZE = (1920, 1080) +MINIMUM_WINDOW_SIZE = (1440, 900) + +#: The action key whose result the status bar is currently showing. +_COMMAND_NAMES = { + "connect": "connection.open", + "disconnect": "connection.close", + "scan": "connection.scan", + "bas": "bus.download", + "reinit": "bus.reinit", +} + + +class AutoCalMainWindow(QMainWindow): + """Platform control, five axes, one window.""" + + def __init__(self, prefs: dict | None = None) -> None: + super().__init__() + self.prefs = dict(prefs or load_preferences()) + self._theme = str(self.prefs.get("theme") or "dark") + self._language = str(self.prefs.get("language") or "zh_CN") + self._last_snapshot = Snapshot() + self._pending: dict[int, str] = {} + self._last_error = "" + + self.setWindowTitle(t("app.title")) + self.setMinimumSize(*MINIMUM_WINDOW_SIZE) + + self._build_ui() + self._build_menus() + self._build_poller() + + self._restore_geometry() + self.retranslate() + self._apply_theme() + + # --------------------------------------------------------------- build UI + + def _build_ui(self) -> None: + central = QWidget(self) + layout = QVBoxLayout(central) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self.brand_bar = BrandBar(self._theme, central) + layout.addWidget(self.brand_bar) + + # The status strip sits above the workspace, outside the tab widget, so + # that switching tabs cannot hide whether the platform is live. The run + # bar sits under it for the same reason: abort must be reachable from + # every tab, not only from the one that started the run. + self.status_strip = StatusStrip(self._theme, central) + layout.addWidget(self.status_strip) + self.run_bar = RunBar(self._theme, central) + layout.addWidget(self.run_bar) + + body = QWidget(central) + body_layout = QHBoxLayout(body) + body_layout.setContentsMargins(8, 6, 8, 6) + body_layout.setSpacing(8) + layout.addWidget(body, stretch=1) + + # ----------------------------------------------------------------- rail + # One rail, not two. It holds setup, the axis readout and the collapsed + # bus data, and it scrolls: at 900 px high those three groups do not fit, + # and a rail that clips its own controls is worse than one that scrolls. + self.rail_content = QWidget() + left = QVBoxLayout(self.rail_content) + left.setContentsMargins(0, 0, 0, 0) + left.setSpacing(8) + + connection = self.prefs.get("connection") or {} + self.connection_panel = ConnectionPanel( + ConnectionSpec( + mode=str(connection.get("mode") or "eth"), + ip=str(connection.get("ip") or ""), + com_id=int(connection.get("com_id") or 0), + card_num=int(connection.get("card_num") or 0), + local_string=str(connection.get("local_string") or "0"), + ), + self.rail_content, + ) + self.bus_panel = BusPanel(str(self.prefs.get("bas_path") or ""), self.rail_content) + self.axis_readout = AxisReadoutPanel( + MotionService().axis_count, self._theme, self.rail_content + ) + left.addWidget(self.connection_panel) + left.addWidget(self.bus_panel) + left.addWidget(self.axis_readout) + + # Bus data (SDO/RTEX) is commissioning-only, so it is collapsed by + # default: it was costing the bottom of the page ~270 px for a control + # used once in a while. + self.bus_data_group = QGroupBox(self.rail_content) + self.bus_data_group.setObjectName("groupBox") + self.bus_data_group.setCheckable(True) + self.bus_data_group.setChecked(False) + bus_data_layout = QVBoxLayout(self.bus_data_group) + bus_data_layout.setContentsMargins(8, 6, 8, 8) + self.bus_data_panel = BusDataPanel( + sdo_last=self.prefs.get("sdo_last") or {}, + rtex_last=self.prefs.get("rtex_last") or {}, + axis_count=MotionService().axis_count, + parent=self.bus_data_group, + ) + bus_data_layout.addWidget(self.bus_data_panel) + self.bus_data_panel.setVisible(False) + self.bus_data_group.toggled.connect(self._on_bus_data_toggled) + left.addWidget(self.bus_data_group) + left.addStretch(1) + + self.left_rail = QScrollArea(body) + self.left_rail.setObjectName("railScroll") + self.left_rail.setWidget(self.rail_content) + self.left_rail.setWidgetResizable(True) + self.left_rail.setFrameShape(QScrollArea.Shape.NoFrame) + self.left_rail.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.left_rail.setMinimumWidth(300) + + # ------------------------------------------------------------ workspace + params = { + axis: AxisParams(**values) + for axis, values in _safe_axis_params(self.prefs.get("axis_params")) + } + self.axis_panel = AxisPanel( + MotionService().axis_count, + params, + units_per_mm=float(self.prefs.get("units_per_mm") or 10.0), + parent=body, + ) + self.sequence_panel = SequencePanel(self.prefs.get("sequence_profile") or {}, body) + self.workspace = WorkspaceTabs(self.axis_panel, self.sequence_panel, body) + + self.main_splitter = QSplitter(Qt.Orientation.Horizontal, body) + self.main_splitter.addWidget(self.left_rail) + self.main_splitter.addWidget(self.workspace) + self.main_splitter.setSizes(list(MAIN_SPLITTER_SIZES)) + self.main_splitter.setChildrenCollapsible(False) + self.main_splitter.setStretchFactor(0, 0) + self.main_splitter.setStretchFactor(1, 1) + self.main_splitter.setObjectName("mainSplitter") + body_layout.addWidget(self.main_splitter, 1) + + self.diagnostics_box = self._build_diagnostics(central) + layout.addWidget(self.diagnostics_box) + + self.setCentralWidget(central) + self.statusBar().showMessage(t("status.ready")) + self._apply_chrome_colours() + + self._wire_signals() + + def _wire_signals(self) -> None: + self.connection_panel.connectRequested.connect(self._on_connect) + self.connection_panel.disconnectRequested.connect(self._on_disconnect) + self.connection_panel.scanRequested.connect(self._on_scan) + self.bus_panel.chooseRequested.connect(self._on_choose_bas) + self.bus_panel.downloadRequested.connect(self._on_download_bas) + self.bus_panel.reinitRequested.connect(self._on_reinit) + self.axis_panel.paramsApplied.connect(self._on_params_applied) + self.axis_panel.enableToggled.connect(self._on_enable_toggled) + self.axis_panel.jogRequested.connect(self._on_jog) + self.axis_panel.stopRequested.connect(self._on_stop) + self.axis_panel.stopAllRequested.connect(self._on_stop_all) + self.axis_panel.moveAbsRequested.connect(self._on_move_abs) + self.axis_panel.moveRelRequested.connect(self._on_move_rel) + self.axis_panel.homeRequested.connect(self._on_home) + self.axis_panel.clearAlarmRequested.connect(self._on_clear_alarm) + self.bus_data_panel.sdoReadRequested.connect(self._on_sdo_read) + self.bus_data_panel.sdoWriteRequested.connect(self._on_sdo_write) + self.bus_data_panel.rtexReadRequested.connect(self._on_rtex_read) + self.bus_data_panel.rtexWriteRequested.connect(self._on_rtex_write) + self.status_strip.stopAllRequested.connect(self._on_stop_all) + self.status_strip.pauseRequested.connect(self._on_pause_polling) + self.run_bar.runRequested.connect(self._on_run_requested) + self.run_bar.pauseRequested.connect(self._on_run_pause) + self.run_bar.abortRequested.connect(self._on_run_abort) + self.axis_readout.stopAllRequested.connect(self._on_stop_all) + self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms) + self.axis_readout.enableAllRequested.connect(self._on_enable_all) + + # ---------------------------------------------------------------- run bar + + def _on_run_requested(self) -> None: + """Refuse, naming the phase that owns the runner. + + Connected rather than left dangling: a control that does nothing when + pressed is indistinguishable from a bug, and the point of wiring this now + is that the sooner it exists, the sooner the phase that fills it has a + seam to build against. + """ + self._report_run("run.not_implemented", ok=False) + + def _on_run_pause(self) -> None: + self.run_bar.set_phase("paused" if self.run_bar.phase != "paused" else "running") + + def _on_run_abort(self) -> None: + # Abort always works, even before the runner exists: it stops the axes. + self.run_bar.set_phase("failed") + self._on_stop_all() + self._report_run("run.abort_stopped", ok=True) + + def _report_run(self, locale_key: str, *, ok: bool) -> None: + text = t(locale_key) + self.statusBar().showMessage(text) + self.status_strip.set_action_result(text, ok) + + def _apply_chrome_colours(self) -> None: + """Style the status bar from the theme rather than Qt's default. + + ``brand_bar_bg`` is a dark navy that reads well as a header and heavy as + a footer, so the bar gets its own token with the brand colour as the + fallback for a theme that predates it. + """ + colours = load_colors(self._theme) + background = colours.get("status_bar_bg") or colours.get("brand_bar_bg", "#12315D") + text = colours.get("status_bar_text") or "#ffffff" + self.statusBar().setStyleSheet( + f"QStatusBar {{ background-color: {background}; color: {text}; }}" + f"QStatusBar::item {{ border: none; }}" + ) + + def _on_bus_data_toggled(self, expanded: bool) -> None: + self.bus_data_panel.setVisible(bool(expanded)) + self.bus_data_group.setMaximumHeight(16777215 if expanded else 28) + + def _on_pause_polling(self) -> None: + """Stop or resume the state poll, without touching the connection.""" + paused = self.status_strip.paused + if paused: + self.poller.pause() + self.statusBar().showMessage(t("status.paused")) + else: + self.poller.resume() + self.statusBar().showMessage(t("status.ready")) + # The health chip says "paused" rather than ageing into a false alarm. + self.status_strip.refresh_health() + + def _on_enable_all(self, enabled: bool) -> None: + for axis in range(self.axis_panel.axis_count): + self.poller.submit( + lambda service, a=axis, on=enabled: service.set_axis_enable(a, on) + ) + + def _on_clear_all_alarms(self) -> None: + for axis in range(self.axis_panel.axis_count): + self.poller.submit(lambda service, a=axis: service.clear_alarm(a)) + + def _build_diagnostics(self, parent: QWidget) -> QGroupBox: + box = QGroupBox(parent) + box.setObjectName("groupBox") + layout = QVBoxLayout(box) + layout.setContentsMargins(10, 8, 10, 10) + layout.setSpacing(4) + self.diagnostics_label = QLabel(box) + self.diagnostics_label.setObjectName("hintLabel") + self.diagnostics_label.setWordWrap(True) + self.diagnostics_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + layout.addWidget(self.diagnostics_label) + box.setVisible(bool(self.prefs.get("show_diagnostics"))) + return box + + def _build_menus(self) -> None: + menu_bar = self.menuBar() + + self.menu_file = menu_bar.addMenu("") + self.act_download_bas = QAction("", self) + self.act_download_bas.triggered.connect(self._on_download_bas) + self.act_reveal_config = QAction("", self) + self.act_reveal_config.triggered.connect(self._on_reveal_config) + self.act_reveal_outputs = QAction("", self) + self.act_reveal_outputs.triggered.connect(self._on_reveal_outputs) + self.act_quit = QAction("", self) + self.act_quit.triggered.connect(self.close) + for action in ( + self.act_download_bas, + self.act_reveal_config, + self.act_reveal_outputs, + ): + self.menu_file.addAction(action) + self.menu_file.addSeparator() + self.menu_file.addAction(self.act_quit) + + self.menu_view = menu_bar.addMenu("") + self.menu_theme = self.menu_view.addMenu("") + 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.act_theme_dark.triggered.connect(lambda: self._set_theme("dark")) + self.act_theme_light.triggered.connect(lambda: self._set_theme("light")) + self.menu_theme.addAction(self.act_theme_dark) + self.menu_theme.addAction(self.act_theme_light) + + self.menu_language = self.menu_view.addMenu("") + 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.act_lang_zh.triggered.connect(lambda: self._set_language("zh_CN")) + self.act_lang_en.triggered.connect(lambda: self._set_language("en_US")) + self.menu_language.addAction(self.act_lang_zh) + self.menu_language.addAction(self.act_lang_en) + + self.act_diagnostics = QAction("", self) + self.act_diagnostics.setCheckable(True) + self.act_diagnostics.setChecked(bool(self.prefs.get("show_diagnostics"))) + self.act_diagnostics.toggled.connect(self._set_diagnostics) + self.menu_view.addSeparator() + self.menu_view.addAction(self.act_diagnostics) + + # Tab shortcuts: the workspace is the page, so reaching a tab from the + # keyboard is the navigation an operator uses most. + self.menu_tabs = self.menu_view.addMenu("") + self.act_tabs: dict[str, QAction] = {} + for index, (key, locale_key) in enumerate(TABS): + action = QAction("", self) + action.setShortcut(f"Ctrl+{index + 1}") + action.triggered.connect(lambda _checked=False, k=key: self.workspace.set_current_key(k)) + self.menu_tabs.addAction(action) + self.act_tabs[key] = action + self._tab_locale_keys = dict(TABS) + + self.menu_help = menu_bar.addMenu("") + self.act_about = QAction("", self) + self.act_about.triggered.connect(self._on_about) + self.menu_help.addAction(self.act_about) + + def _build_poller(self) -> None: + self.poller = MotionPoller( + MotionService(language=self._language), + axes=range(self.axis_panel.axis_count), + ) + self.poller.snapshotReady.connect(self._on_snapshot) + self.poller.commandFinished.connect(self._on_command_finished) + self.poller.commandFailed.connect(self._on_command_failed) + self.poller.set_interval_ms(int(self.prefs.get("state_poll_ms") or 100)) + self.status_strip.set_poll_interval_ms(int(self.prefs.get("state_poll_ms") or 100)) + + # Ages the poll-health chip even when no snapshot arrives. Runs on the + # GUI thread and only reads a timestamp — the controller is untouched — + # so a stopped poll becomes visible instead of leaving stale chips + # looking healthy. + self._health_timer = QTimer(self) + self._health_timer.timeout.connect(self.status_strip.refresh_health) + self._health_timer.start(500) + + # ------------------------------------------------------------- controller + + def connect_controller( + self, spec: ConnectionSpec | None = None, *, label: str = "connect" + ) -> int: + """Ask the poller to open the controller. Returns the command id.""" + target = spec or self.connection_panel.spec + token = self.poller.connect_controller(target) + self._pending[token] = label + self.connection_panel.set_status(t("connection.state_connecting")) + return token + + def disconnect_controller(self, *, label: str = "disconnect") -> int: + token = self.poller.disconnect_controller() + self._pending[token] = label + return token + + # --------------------------------------------------------------- snapshot + + def _on_snapshot(self, snapshot: Snapshot) -> None: + """Signal handler for the poller's periodic state read.""" + self.update_snapshot(snapshot) + + def update_snapshot(self, snapshot: Snapshot) -> None: + self._last_snapshot = snapshot + self.connection_panel.set_connected( + snapshot.connected, self.poller.service.transport.source + ) + self.brand_bar.set_connected(snapshot.connected) + self.bus_panel.set_connected(snapshot.connected) + self.bus_panel.set_status(snapshot.bus) + # The bus-data panel gates on the connection first and the bus family + # second, so both have to reach it or every SDO/RTEX control stays dead. + self.bus_data_panel.set_connected(snapshot.connected) + self.bus_data_panel.set_bus_type(snapshot.bus.bus_type) + self.axis_panel.update_snapshot(snapshot) + self.axis_readout.update_snapshot(snapshot) + # The strip is outside the tabs, so it is fed on every poll regardless + # of which tab is showing. + self.status_strip.set_snapshot(snapshot) + # The run bar tracks the designed point list: before a runner exists that + # is zero, and its abort control stays correct either way. + self.run_bar.set_progress(0, len(self.sequence_panel.planned_steps())) + self.run_bar.set_run(bool(self.sequence_panel.planned_steps())) + if snapshot.error: + self._last_error = snapshot.error + self._refresh_status_bar() + if self.diagnostics_box.isVisible(): + self._refresh_diagnostics() + + def _refresh_status_bar(self) -> None: + snapshot = self._last_snapshot + if not snapshot.connected: + self.statusBar().showMessage(t("status.ready")) + return + parts = [ + t("status.poll_paused") if self.status_strip.paused else t("status.poll_ok"), + t("status.poll_every", ms=self.poller.interval_ms), + ] + if snapshot.bus.init_status == 1: + parts.append(t("bus.state_ok")) + if self._last_error: + parts.append(f"{t('status.last_error')}: {self._last_error}") + self.statusBar().showMessage(" | ".join(parts)) + + def _refresh_diagnostics(self) -> None: + from ..preferences import path as preference_path + + service = self.poller.service + self.diagnostics_label.setText( + "\n".join( + [ + f"{t('diagnostics.binding')}: PySide6 {_pyside_version()}", + f"{t('diagnostics.dll_dir')}: {service.transport.source}", + f"{t('diagnostics.prefs')}: {preference_path()}", + f"{t('diagnostics.outputs')}: {outputs_dir()}", + f"{t('diagnostics.bas_loaded')}: " + f"{t('diagnostics.yes') if service.bas_loaded else t('diagnostics.no')}", + ] + ) + ) + + # ----------------------------------------------------- command completion + + def _on_command_finished(self, token: int, result) -> None: + """Report a completed command, whatever shape its result has. + + Commands return different things: a ``MotionResult``, a list of them + (stop-all), a ``(result, value)`` pair (SDO/RTEX reads), a list of IPs + (scan), or ``None``. Rather than a convention each caller must remember, + the shape is detected here. + """ + label_key = _COMMAND_NAMES.get(self._pending.pop(token, ""), "") + name = t(label_key) if label_key else "" + + message, ok = _interpret_result(result) + if ok: + self._last_error = "" + if message: + self.bus_data_panel.set_result(message) + text = f"{name} {t('action.ok')}".strip() + self.statusBar().showMessage(text) + # The strip keeps the outcome visible after a tab switch, so a + # command that was refused while the operator was elsewhere is not + # lost the moment the status bar is overwritten. + self.status_strip.set_action_result(text, True) + return + + self._last_error = message or t("action.failed") + self.bus_data_panel.set_result(self._last_error) + text = f"{name} {t('action.refused')}: {self._last_error}" + self.statusBar().showMessage(text) + self.status_strip.set_action_result(text, False) + logger.warning("Command refused/failed: %s", self._last_error) + + def _on_command_failed(self, token: int, message: str) -> None: + label_key = _COMMAND_NAMES.get(self._pending.pop(token, ""), "") + self._last_error = message + name = t(label_key) if label_key else "" + text = f"{name} {t('action.failed')}: {message}" + self.statusBar().showMessage(text) + self.status_strip.set_action_result(text, False) + logger.error("Command raised: %s", message) + + # ---------------------------------------------------------------- handlers + + def _on_connect(self, spec: ConnectionSpec) -> None: + self.connect_controller(spec) + + def _on_disconnect(self) -> None: + if not self._last_snapshot.connected: + return + moving = [ + state.axis + for state in self._last_snapshot.axes + if state.enabled and not state.idle + ] + if moving: + answer = QMessageBox.question( + self, + t("connection.close"), + t("axes.motion") + f" {moving}", + ) + if answer != QMessageBox.StandardButton.Yes: + return + self.disconnect_controller() + + def _on_scan(self) -> None: + def _scan(service: MotionService) -> object: + return service.transport.search_eth() + + token = self.poller.submit(_scan) + self._pending[token] = "scan" + + def _on_choose_bas(self) -> None: + start = self.bus_panel.bas_path or str(default_bas_dir()) + path, _selected = QFileDialog.getOpenFileName( + self, t("bus.dialog_title"), start, "BAS (*.bas);;All files (*)" + ) + if path: + self.bus_panel.set_bas_path(path) + self.prefs["bas_path"] = path + + def _on_download_bas(self) -> None: + path = self.bus_panel.bas_path + if not path: + self._on_choose_bas() + path = self.bus_panel.bas_path + if not path: + return + token = self.poller.submit(lambda service: service.download_bas(path, 1)) + self._pending[token] = "bas" + self.statusBar().showMessage(t("bus.downloaded")) + + def _on_reinit(self) -> None: + token = self.poller.submit(lambda service: service.reinit_bus()) + self._pending[token] = "reinit" + + def _on_params_applied(self, axis: int, params: AxisParams) -> None: + token = self.poller.submit(lambda service: service.set_axis_params(axis, params)) + self._pending[token] = "" + + def _on_enable_toggled(self, axis: int, enabled: bool) -> None: + token = self.poller.submit(lambda service: service.set_axis_enable(axis, enabled)) + self._pending[token] = "" + + def _on_jog(self, axis: int, forward: bool) -> None: + self.poller.submit(lambda service: service.jog(axis, forward)) + + def _on_stop(self, axis: int) -> None: + self.poller.submit(lambda service: service.stop(axis)) + + def _on_stop_all(self) -> None: + def _stop_all(service: MotionService) -> object: + return service.stop_all() + + self.poller.submit(_stop_all) + + def _on_move_abs(self, axis: int, target: float) -> None: + self.poller.submit(lambda service: service.move_abs(axis, target)) + + def _on_move_rel(self, axis: int, distance: float) -> None: + self.poller.submit(lambda service: service.move_rel(axis, distance)) + + def _on_home(self, axis: int) -> None: + self.poller.submit(lambda service: service.home(axis)) + + def _on_clear_alarm(self, axis: int) -> None: + self.poller.submit(lambda service: service.clear_alarm(axis)) + + # ---------------------------------------------------------------- bus data + + def _on_sdo_read(self, node: int, index: int, subindex: int, data_type: int) -> None: + def _read(service: MotionService): + result, value = service.sdo_read(node, index, subindex, data_type) + return result, value + + token = self.poller.submit(_read) + self._pending[token] = "" + + def _on_sdo_write( + self, node: int, index: int, subindex: int, data_type: int, value: int + ) -> None: + token = self.poller.submit( + lambda service: service.sdo_write(node, index, subindex, data_type, value) + ) + self._pending[token] = "" + + def _on_rtex_read(self, axis: int, parameter: int) -> None: + token = self.poller.submit(lambda service: service.rtex_read(axis, parameter)) + self._pending[token] = "" + + def _on_rtex_write(self, axis: int, parameter: int, value: float) -> None: + token = self.poller.submit( + lambda service: service.rtex_write(axis, parameter, value) + ) + self._pending[token] = "" + + # ------------------------------------------------------------------ menus + + def _set_theme(self, theme: str) -> None: + self._theme = theme + self.prefs["theme"] = theme + self._apply_theme() + self._persist_prefs() + + def _apply_theme(self) -> None: + app = QApplication.instance() + if app is not None: + app.setStyleSheet(build_qss(self._theme)) + self.brand_bar.set_theme(self._theme) + self.status_strip.set_theme(self._theme) + self.run_bar.set_theme(self._theme) + self.axis_readout.set_theme(self._theme) + self._apply_chrome_colours() + self.act_theme_dark.setChecked(self._theme == "dark") + self.act_theme_light.setChecked(self._theme == "light") + + def _set_language(self, language: str) -> None: + self._language = language + self.prefs["language"] = language + set_locale(language) + self.poller.set_language(language) + self.retranslate() + self._persist_prefs() + + def _set_diagnostics(self, visible: bool) -> None: + """Show or hide the diagnostics box. + + It used to be a splitter pane, which meant toggling it resized the whole + page. Now it is a fixed footer: the layout above it does not move, so an + operator cannot lose their place by turning the diagnostics on. + """ + self.prefs["show_diagnostics"] = bool(visible) + self.diagnostics_box.setVisible(bool(visible)) + if visible: + self._refresh_diagnostics() + self._persist_prefs() + + def _on_reveal_config(self) -> None: + from orisys_app_shared.paths import config_dir + + self._reveal(config_dir()) + + def _on_reveal_outputs(self) -> None: + self._reveal(outputs_dir()) + + @staticmethod + def _reveal(path: Path) -> None: + try: + if sys.platform == "win32": + subprocess.Popen(["explorer", str(path)]) + else: # pragma: no cover - the platform is Windows + subprocess.Popen(["xdg-open", str(path)]) + except OSError: + logger.exception("Could not open %s", path) + + def _on_about(self) -> None: + QMessageBox.information( + self, + t("app.about_title"), + t( + "app.about_body", + app_version=app_version, + shared_version=shared_version, + api_version=API_VERSION, + binding=f"PySide6 {_pyside_version()}", + dll_dir=self.poller.service.transport.source, + prefs_path=self.prefs_path(), + ), + ) + + def prefs_path(self) -> str: + from ..preferences import path as preference_path + + return str(preference_path()) + + # ------------------------------------------------------------- translations + + def retranslate(self) -> None: + self.setWindowTitle(f"{t('app.title')} {app_version}") + self.brand_bar.retranslate() + self.status_strip.retranslate() + self.run_bar.retranslate() + self.connection_panel.retranslate() + self.bus_panel.retranslate() + self.bus_data_group.setTitle(t("busdata.group")) + self.bus_data_panel.retranslate() + self.axis_panel.retranslate() + self.axis_readout.retranslate() + self.sequence_panel.retranslate() + self.workspace.retranslate() + + self.menu_file.setTitle(t("menu.file")) + self.act_download_bas.setText(t("menu.open_bas")) + self.act_reveal_config.setText(t("menu.reveal_config")) + self.act_reveal_outputs.setText(t("menu.outputs")) + self.act_quit.setText(t("menu.quit")) + self.menu_view.setTitle(t("menu.view")) + self.menu_theme.setTitle(t("menu.theme")) + self.act_theme_dark.setText(t("menu.theme_dark")) + self.act_theme_light.setText(t("menu.theme_light")) + self.menu_language.setTitle(t("menu.language")) + self.act_lang_zh.setText(t("menu.lang_zh")) + self.act_lang_en.setText(t("menu.lang_en")) + self.act_diagnostics.setText(t("menu.show_diagnostics")) + self.menu_tabs.setTitle(t("menu.tabs")) + for key, locale_key in self._tab_locale_keys.items(): + self.act_tabs[key].setText(t(locale_key)) + self.menu_help.setTitle(t("menu.help")) + self.act_about.setText(t("menu.about")) + self.act_lang_zh.setChecked(self._language == "zh_CN") + self.act_lang_en.setChecked(self._language == "en_US") + self.diagnostics_box.setTitle(t("diagnostics.title")) + self._refresh_status_bar() + + # ------------------------------------------------------------ persistence + + def _restore_geometry(self) -> None: + geometry = self.prefs.get("window_geometry") + if isinstance(geometry, list) and len(geometry) == 4: + try: + self.setGeometry(*(int(value) for value in geometry)) + self._apply_splitter_sizes() + return + except (TypeError, ValueError): + logger.warning("Ignoring malformed window_geometry: %r", geometry) + self.resize(*DEFAULT_WINDOW_SIZE) + self._apply_splitter_sizes() + + def _apply_splitter_sizes(self) -> None: + """Restore the column split, or fall back to the designed proportions. + + The previous layout stored three column widths in ``splitter_sizes`` for + a different splitter; that key no longer describes anything, so it is + ignored rather than misapplied. + """ + stored = self.prefs.get("main_splitter_sizes") + if isinstance(stored, list) and len(stored) == self.main_splitter.count(): + try: + self.main_splitter.setSizes([int(value) for value in stored]) + return + except (TypeError, ValueError): + logger.warning("Ignoring malformed main_splitter_sizes: %r", stored) + self.main_splitter.setSizes(list(MAIN_SPLITTER_SIZES)) + + def _persist_prefs(self) -> None: + """Write the current UI state to AutoCal's preference file.""" + geometry = self.geometry() + self.prefs["window_geometry"] = [ + geometry.x(), + geometry.y(), + geometry.width(), + geometry.height(), + ] + self.prefs["main_splitter_sizes"] = [int(v) for v in self.main_splitter.sizes()] + self.prefs["bas_path"] = self.bus_panel.bas_path + self.prefs["units_per_mm"] = self.axis_panel._units_per_mm + self.prefs["axis_params"] = { + str(axis): _params_to_dict(params) + for axis, params in self.axis_panel.stored_params().items() + } + self.prefs.update(self.bus_data_panel.remember_state()) + # The sequence profile is the P5 design artefact: whatever is on screen is + # what a later run would use, so it is persisted with everything else. + self.prefs["sequence_profile"] = self.sequence_panel.profile() + try: + save_preferences(self.prefs) + except OSError: + logger.exception("Could not write the preference file") + + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override + """Stop the platform, close the controller, then persist and leave.""" + logger.info("Closing: stopping axes and shutting the poller down") + try: + self.poller.submit(lambda service: service.stop_all()) + self.poller.disconnect_controller() + self.poller.shutdown() + except Exception: # noqa: BLE001 - closing must always finish + logger.exception("Shutdown reported a problem") + self._persist_prefs() + event.accept() + + +def _pyside_version() -> str: + """The installed PySide6 version, for the About box and diagnostics.""" + import PySide6 + + return getattr(PySide6, "__version__", "unknown") + + +def _safe_axis_params(raw: object) -> list[tuple[int, dict]]: + """Turn the stored per-axis dict into ``AxisParams`` keyword groups. + + A preference file written by an older build (or edited by hand) may be the + wrong shape; a bad entry is dropped rather than crashing the window. + """ + known = set(AxisParams.__dataclass_fields__) + result: list[tuple[int, dict]] = [] + if not isinstance(raw, dict): + return result + for key, values in raw.items(): + if not isinstance(values, dict): + continue + try: + axis = int(key) + except (TypeError, ValueError): + continue + clean = {name: value for name, value in values.items() if name in known} + try: + result.append((axis, clean)) + except (TypeError, ValueError): + logger.warning("Ignoring malformed axis_params[%r]: %r", key, values) + return result + + +def _params_to_dict(params: AxisParams) -> dict: + return { + name: getattr(params, name) for name in AxisParams.__dataclass_fields__ + } + + +def _interpret_result(result) -> tuple[str, bool]: + """Reduce a command's result to ``(message, ok)``. + + See :meth:`AutoCalMainWindow._on_command_finished` for why this is needed + rather than each caller knowing its own result shape. + """ + if result is None: + return "", True + + # A single MotionResult (most commands). + if hasattr(result, "ok"): + return str(getattr(result, "message", "") or ""), bool(result.ok) + + # A (MotionResult, value) pair from the SDO/RTEX reads. Checked before the + # plain-list case, because a tuple's first element is a MotionResult here + # while a stop-all list's items are. + if isinstance(result, tuple) and len(result) == 2 and hasattr(result[0], "ok"): + head, value = result + if not head.ok: + return str(getattr(head, "message", "") or ""), False + return t("busdata.read_ok", value=value), True + + # A list of MotionResults (stop-all): ok when every one succeeded, and the + # first refusal's reason is the useful thing to show. + if isinstance(result, (list, tuple)) and result and hasattr(result[0], "ok"): + failures = [item for item in result if not item.ok] + if failures: + return str(getattr(failures[0], "message", "") or ""), False + return "", True + + # A scanned address list. + if isinstance(result, (list, tuple)): + return ", ".join(str(item) for item in result), True + + return str(result), True diff --git a/apps/autocal/src/orisys_autocal/ui/workspace.py b/apps/autocal/src/orisys_autocal/ui/workspace.py new file mode 100644 index 0000000..c0fe890 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/workspace.py @@ -0,0 +1,130 @@ +"""The single-page workspace. + +One tab per task the operator does on the platform, with the motion status strip +living *outside* this widget so a tab change cannot hide the state that decides +whether it is safe to act. + +Pages that are not built yet are present as labelled placeholders rather than +missing: the layout is being reviewed for operator use, and a tab that appears +only later would change the layout under review. Each placeholder names what +will fill it, so nobody mistakes an empty page for a broken one. +""" + +from __future__ import annotations + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QLabel, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from orisys_app_shared.i18n import t + +from .axis_panel import AxisPanel +from .sequence_panel import SequencePanel + +#: Tab identity -> locale key for its label. Order is the display order, and it +#: follows the work: commission the axes, describe the sequence, watch the +#: sensor, look at the surface, then record calibration points. +TABS: tuple[tuple[str, str], ...] = ( + ("axes", "workspace.tab_axes"), + ("sequence", "workspace.tab_sequence"), + ("sensor", "workspace.tab_sensor"), + ("surface", "workspace.tab_surface"), + ("points", "workspace.tab_points"), +) + +#: Tabs whose content does not exist yet, with the locale key that explains what +#: will appear there and when. +_PLACEHOLDERS: dict[str, str] = { + "sensor": "workspace.reserved_sensor", + "surface": "workspace.reserved_surface", + "points": "workspace.reserved_points", +} + + +class PlaceholderPage(QWidget): + """An empty page that says what it is waiting for.""" + + def __init__(self, locale_key: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._locale_key = locale_key + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(8) + self.headline = QLabel(self) + self.headline.setObjectName("placeholderTitle") + self.body = QLabel(self) + self.body.setObjectName("hintLabel") + self.body.setWordWrap(True) + layout.addWidget(self.headline) + layout.addWidget(self.body) + layout.addStretch(1) + self.retranslate() + + def retranslate(self) -> None: + self.headline.setText(t("workspace.not_built")) + self.body.setText(t(self._locale_key)) + + +class WorkspaceTabs(QTabWidget): + """The task workspace, with a stable tab set.""" + + pageChanged = Signal(str) + + def __init__( + self, + axis_panel: AxisPanel, + sequence_panel: SequencePanel, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName("workspaceTabs") + self._pages: dict[str, QWidget] = {} + self._placeholder_keys: dict[str, str] = {} + + self._pages["axes"] = axis_panel + self._pages["sequence"] = sequence_panel + for key, locale_key in _PLACEHOLDERS.items(): + page = PlaceholderPage(locale_key, self) + self._pages[key] = page + self._placeholder_keys[key] = locale_key + + for key, _locale_key in TABS: + self.addTab(self._pages[key], "") + + self.currentChanged.connect(self._on_current_changed) + self.retranslate() + + # -------------------------------------------------------------------- state + + def page_keys(self) -> list[str]: + return [key for key, _locale_key in TABS] + + def page(self, key: str) -> QWidget: + return self._pages[key] + + def current_key(self) -> str: + index = self.currentIndex() + keys = self.page_keys() + return keys[index] if 0 <= index < len(keys) else keys[0] + + def set_current_key(self, key: str) -> None: + keys = self.page_keys() + if key in keys: + self.setCurrentIndex(keys.index(key)) + + def retranslate(self) -> None: + for index, (key, locale_key) in enumerate(TABS): + self.setTabText(index, t(locale_key)) + for key, locale_key in self._placeholder_keys.items(): + page = self._pages[key] + if isinstance(page, PlaceholderPage): + page.retranslate() + + def _on_current_changed(self, index: int) -> None: + keys = self.page_keys() + if 0 <= index < len(keys): + self.pageChanged.emit(keys[index]) diff --git a/apps/autocal/tests/conftest.py b/apps/autocal/tests/conftest.py new file mode 100644 index 0000000..878d3a7 --- /dev/null +++ b/apps/autocal/tests/conftest.py @@ -0,0 +1,137 @@ +"""Fixtures for the AutoCal tests. + +Everything here runs offscreen with no controller and no hardware. Two +environment facts are set before Qt is imported: + +* ``QT_QPA_PLATFORM=offscreen`` so a window can be built without a display; +* ``QT_QPA_FONTDIR`` because the offscreen platform finds no fonts on Windows + otherwise, and every label would measure as zero-width. + +The suite requires PySide6 — the app's only Qt binding. Where it is missing +(an older interpreter, or a machine with no way to install it) the tests are +reported as skipped rather than failing, so a broken environment cannot look +like a broken application. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +os.environ.setdefault("ORISYS_APP_ID", "autocal") +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) + + +def _have_pyside6() -> tuple[bool, str]: + try: + import PySide6 + except ImportError as exc: + return False, str(exc) + return True, getattr(PySide6, "__version__", "unknown") + + +HAVE_PYSIDE6, PYSIDE6_VERSION = _have_pyside6() + +if not HAVE_PYSIDE6: # pragma: no cover - depends on the machine + collect_ignore_glob = ["test_*.py"] + + +@pytest.fixture(scope="session") +def qt_app(): + """One ``QApplication`` per session (Qt forbids more than one).""" + if not HAVE_PYSIDE6: # pragma: no cover + pytest.skip("PySide6 is not installed") + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() or QApplication([]) + yield app + + +SCRATCH_ROOT = Path(__file__).resolve().parents[1] / "outputs" / "_test_scratch" + + +def _scratch_dir() -> Path: + """A fresh scratch directory under the app's own (gitignored) outputs.""" + import time + + path = SCRATCH_ROOT / f"{os.getpid()}-{int(time.time() * 1000) % 1_000_000}" + path.mkdir(parents=True, exist_ok=True) + return path + + +@pytest.fixture +def make_window(qt_app, monkeypatch): + """Build a window whose preference writes stay inside a scratch directory. + + Deliberately not ``tmp_path``: this suite has to run inside a sandboxed + shell, and ``tmp_path`` depends on a writable process temp root that such an + environment may not provide. Using the app's own ``outputs/`` (already + gitignored) removes that dependency entirely. + """ + import shutil + + from orisys_app_shared import paths as paths_mod + from orisys_app_shared import prefs as prefs_pkg + + from orisys_autocal import preferences as prefs_mod + from orisys_autocal.motion import DemoTransport + from orisys_autocal.ui.window import AutoCalMainWindow + + scratch = _scratch_dir() + prefs_file = scratch / "preference.json" + monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file) + monkeypatch.setattr(prefs_pkg.store, "preference_path", lambda: prefs_file) + monkeypatch.setattr(prefs_mod, "preference_path", lambda: prefs_file) + + created = [] + + def _make(**overrides): + prefs = prefs_mod.default_preferences() + prefs.update(overrides) + # The translator is process-global, so a window built after another test + # switched language would otherwise keep that language and then be + # labelled with the wrong locale. + from orisys_app_shared.i18n import set_locale + + set_locale(str(prefs.get("language") or "zh_CN")) + window = AutoCalMainWindow(prefs=prefs) + # Swap in the simulated platform: no test in this suite touches a DLL. + window.poller.service._transport = DemoTransport() # noqa: SLF001 + created.append(window) + return window + + yield _make + + for window in created: + try: + window.poller.shutdown() + window.close() + except Exception: # noqa: BLE001 - teardown must not mask a failure + pass + shutil.rmtree(scratch, ignore_errors=True) + + +def pump(app, seconds: float = 0.2) -> None: + """Run the event loop for a short, bounded time.""" + import time + + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + app.processEvents() + time.sleep(0.005) + + +def visible_descendants(widget): + """Yield every visible descendant 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/autocal/tests/test_binding_purity.py b/apps/autocal/tests/test_binding_purity.py new file mode 100644 index 0000000..cfdc6f4 --- /dev/null +++ b/apps/autocal/tests/test_binding_purity.py @@ -0,0 +1,185 @@ +"""Binding policy for AutoCal. + +AutoCal is **PySide6 only**, and this file is the guard for that: + +* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has + PySide6 and nothing else, so such an import is a hard start-up failure; +* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``), which PySide6 + removed in favour of ``Qt.AlignmentFlag.AlignLeft``; +* ``shared/`` — which serves both this app and the PyQt5-based studio — must + import no Qt binding at all; +* the controller layer must stay importable from a headless process. + +The reason the binding matters at all is licensing, not taste: PyQt5's free +option is GPLv3-or-commercial, while PySide6 is LGPLv3, so an internal +closed-source tool needs no licence purchase. See ``README.md``. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +APP_SRC = Path(__file__).resolve().parents[1] / "src" +SHARED_SRC = Path(__file__).resolve().parents[3] / "shared" / "src" + +#: Bindings the app must never import. +FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"} + +#: The one binding allowed, and where it may be named. +ALLOWED_BINDING = "PySide6" + +#: Scoped enum holders we care about, with the value names actually used. +_SCOPED_ENUMS = { + "Qt": ( + "AlignmentFlag", + "ItemDataRole", + "Orientation", + "ConnectionType", + "TextInteractionFlag", + "TransformationMode", + "ToolButtonStyle", + "FocusPolicy", + "ScrollBarPolicy", + ), + "QTableWidget": ("SelectionMode", "EditTrigger"), + "QSizePolicy": ("Policy",), + "QHeaderView": ("ResizeMode",), + "QMessageBox": ("StandardButton", "Icon"), + "QDialog": ("DialogCode",), + "QAbstractItemView": ("SelectionMode", "EditTrigger"), +} + + +def _python_files(root: Path) -> list[Path]: + return sorted(path for path in root.rglob("*.py") if "__pycache__" not in path.parts) + + +def _imported_packages(path: Path) -> set[str]: + """Every top-level package name imported by ``path``.""" + tree = ast.parse(path.read_text(encoding="utf-8")) + found: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + found.add(alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + found.add(node.module.split(".")[0]) + return found + + +def _unscoped_enum_references(path: Path) -> list[str]: + """Attribute reads like ``Qt.AlignLeft`` — the spelling PySide6 rejects. + + Parsed rather than pattern-matched for two reasons: a regex also hits prose + in a comment or docstring (which is exactly where the scoped spelling gets + *explained*), and ``QMessageBox.information(...)`` looks like an enumerator + but is a static method. The parent map separates the two, and + ``Qt.AlignmentFlag.AlignLeft`` is an inner attribute on an allow-listed enum + class rather than a plain name, so it is never a candidate. + """ + tree = ast.parse(path.read_text(encoding="utf-8")) + called = { + id(node.func) + for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + } + found: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name): + continue + if id(node) in called: + continue # a method or function call, not an enumerator + holder = node.value.id + enum_names = _SCOPED_ENUMS.get(holder) + if not enum_names or node.attr in enum_names: + continue + found.add(f"{holder}.{node.attr}") + return sorted(found) + + +def test_no_module_imports_a_forbidden_binding(): + offenders = [] + for path in _python_files(APP_SRC): + bad = _imported_packages(path) & FORBIDDEN_BINDINGS + if bad: + offenders.append(f"{path.name}: {sorted(bad)}") + assert not offenders, ( + f"AutoCal is {ALLOWED_BINDING}-only; these modules import another " + f"binding: {offenders}" + ) + + +def test_every_ui_module_imports_the_allowed_binding(): + """A UI file that names no binding would be using one it does not declare.""" + offenders = [] + for path in _python_files(APP_SRC / "orisys_autocal" / "ui"): + if path.name == "__init__.py": + continue # a re-export barrel, not a widget module + if ALLOWED_BINDING not in _imported_packages(path): + offenders.append(path.name) + assert not offenders, f"these UI modules do not import {ALLOWED_BINDING}: {offenders}" + + +def test_no_module_uses_an_unscoped_enum(): + offenders = [] + for path in _python_files(APP_SRC): + references = _unscoped_enum_references(path) + if references: + offenders.append(f"{path.name}: {references}") + assert not offenders, ( + "unscoped Qt enumerators were removed in PySide6; use the scoped " + f"spelling: {offenders}" + ) + + +def test_the_controller_interface_is_qt_free(): + """The controller and its guards stay importable from a headless process. + + ``poller`` is deliberately absent from this check: it *is* the Qt worker + bridge (``QThread``, signals), and it lives beside the service precisely so + that the service, the transports, the models and the error map do not need + a GUI to be exercised. + """ + qt_free = ("models.py", "errors.py", "units.py", "service.py", "transport.py") + offenders = [] + for path in _python_files(APP_SRC / "orisys_autocal" / "motion"): + if path.name not in qt_free: + continue + qt_imports = _imported_packages(path) & {"PySide2", "PySide6", "PyQt5", "PyQt6"} + if qt_imports: + offenders.append(f"{path.name}: {sorted(qt_imports)}") + assert not offenders, offenders + + +def test_shared_imports_no_qt_binding(): + """Shared code serves both products, so it must not pick a side.""" + offenders = [] + for path in _python_files(SHARED_SRC): + qt_imports = _imported_packages(path) & {"PySide2", "PySide6"} + if qt_imports: + offenders.append(f"{path.name}: {sorted(qt_imports)}") + assert not offenders, ( + "shared code must not import PySide2/PySide6 (studio is PyQt5): " + f"{offenders}" + ) + + +def test_zmotion_client_imports_no_qt_binding(): + zmotion = SHARED_SRC / "orisys_app_shared" / "zmotion" + offenders = [] + for path in _python_files(zmotion): + qt_imports = _imported_packages(path) & {"PySide2", "PySide6", "PyQt5", "PyQt6"} + if qt_imports: + offenders.append(f"{path.name}: {sorted(qt_imports)}") + assert not offenders, offenders + + +def test_the_app_declares_its_binding_dependency(): + """The packaging must state PySide6, not leave it to chance.""" + pyproject = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text( + encoding="utf-8" + ) + assert ALLOWED_BINDING in pyproject + for forbidden in FORBIDDEN_BINDINGS: + assert f'"{forbidden}' not in pyproject, forbidden diff --git a/apps/autocal/tests/test_errors.py b/apps/autocal/tests/test_errors.py new file mode 100644 index 0000000..8395311 --- /dev/null +++ b/apps/autocal/tests/test_errors.py @@ -0,0 +1,54 @@ +"""Controller return-code translation. + +The vendor SDK header defines the auxiliary error block at 30000; everything +else is the firmware's own number. The point of these tests is that AutoCal +never invents a meaning for a code it cannot read out of that header. +""" + +from __future__ import annotations + +import pytest + +from orisys_autocal.motion import errors + +DOCUMENTED = sorted(errors.MESSAGES) + + +def test_zero_is_success_in_both_languages(): + assert errors.describe(0) == "æˆ�功" + assert errors.describe(0, language="en_US") == "OK" + + +def test_the_documented_block_is_the_vendor_offset(): + assert errors.ERR_AUX_OFFSET == 30000 + assert DOCUMENTED == [30000, 30001, 30002, 30003, 30004, 30005] + + +@pytest.mark.parametrize("code", DOCUMENTED) +def test_every_documented_code_has_both_languages(code): + zh = errors.describe(code) + en = errors.describe(code, language="en_US") + assert zh and en and zh != en + + +@pytest.mark.parametrize("code", DOCUMENTED) +def test_no_documented_message_is_empty(code): + assert errors.describe(code).strip() + assert errors.describe(code, language="en_US").strip() + + +def test_an_unknown_code_keeps_its_number(): + """A guessed meaning on a motion controller is worse than 'unknown'.""" + message = errors.describe(12345) + assert "12345" in message + assert message == "错误ç � 12345" + + +def test_an_unknown_code_points_at_zdevelop(): + message = errors.describe_with_hint(-1) + assert "-1" in message + assert errors.UNKNOWN_HINT in message + + +def test_a_documented_code_gets_no_hint_appended(): + assert errors.describe_with_hint(30000) == errors.describe(30000) diff --git a/apps/autocal/tests/test_layout_shell.py b/apps/autocal/tests/test_layout_shell.py new file mode 100644 index 0000000..3d5fae7 --- /dev/null +++ b/apps/autocal/tests/test_layout_shell.py @@ -0,0 +1,469 @@ +"""The single-page shell, from an operator's point of view. + +These assertions are about the layout contract rather than any one panel: + +* the motion status strip is outside the tabs, so it survives every tab change; +* all five tabs exist, are labelled, and their pages fit; +* the right-hand readout rail shows every drive and issues no per-axis command; +* the window refuses to shrink below the size at which the page would clip. + +The overflow check lives in ``test_panel_smoke.py`` because it needs the colour +tables; what is here is the structure. +""" + +from __future__ import annotations + +import pytest + +from conftest import pump + +from orisys_autocal.ui.workspace import TABS + + +def _show(window, size=(1600, 900)): + from PySide6.QtWidgets import QApplication + + window.resize(*size) + window.show() + QApplication.processEvents() + return window + + +def test_the_tab_set_is_the_documented_one(make_window): + window = _show(make_window()) + assert window.workspace.page_keys() == [key for key, _ in TABS] + + +def test_every_tab_has_a_localised_label(make_window): + from orisys_app_shared.i18n import t + + window = _show(make_window()) + for index, (_key, locale_key) in enumerate(TABS): + label = window.workspace.tabText(index) + assert label, f"tab {index} has no label" + assert label == t(locale_key) + assert label != locale_key, f"tab {index} shows its raw key" + + +def test_the_status_strip_survives_every_tab_change(make_window): + """The whole point of putting the strip above the tabs.""" + window = _show(make_window()) + for key in window.workspace.page_keys(): + window.workspace.set_current_key(key) + assert window.status_strip.isVisible(), f"the strip hid on tab {key}" + assert window.status_strip.height() <= 48 + + +def test_the_strip_reports_connection_and_bus_state(make_window): + from orisys_app_shared.i18n import t + + from orisys_autocal.motion import BusStatus, Snapshot + + window = _show(make_window()) + strip = window.status_strip + + strip.set_snapshot(Snapshot(connected=False)) + assert strip.chip_connection.text() == t("connection.state_closed") + assert strip.chip_init.text() == t("bus.state_pending") + + strip.set_snapshot( + Snapshot(connected=True, bus=BusStatus(bus_type="ecat", init_status=1, total_axes=5)) + ) + assert strip.chip_connection.text() == t("connection.state_open") + assert strip.chip_bus.text() == t("bus.type_ecat") + assert strip.chip_init.text() == t("bus.state_ok") + + +def test_a_refusal_stays_visible_on_the_strip(make_window): + """A command refused while the operator was on another tab must not vanish.""" + window = _show(make_window()) + window.workspace.set_current_key("sensor") + window.status_strip.set_action_result("è½´0 回零模å¼�未设置", False) + assert "回零模å¼�" in window.status_strip.action_label.text() + + +def test_the_readout_rail_covers_every_axis(make_window): + window = _show(make_window()) + assert len(window.axis_readout._rows) == window.axis_panel.axis_count == 5 + + +def test_the_readout_rail_shows_position_and_state(make_window): + from orisys_autocal.motion import AxisState, Snapshot + + window = _show(make_window()) + snapshot = Snapshot( + connected=True, + axes=( + AxisState(axis=0, enabled=True, units=10.0, dpos=1000.0, idle=True), + AxisState(axis=1, enabled=False, units=10.0, dpos=0.0, idle=True), + ), + ) + window.axis_readout.update_snapshot(snapshot) + # 1000 user units at units=10 is the documented 2.000 mm, and the readout + # shows both so an operator never has to convert in their head. + text = window.axis_readout._rows[0]["position"].text() + assert "1000.000" in text and "2.000 mm" in text, text + assert window.axis_readout._rows[1]["state"].text() + + +def test_the_readout_rail_issues_no_per_axis_motion(make_window): + """Per-axis commands live in one place only: the Axis-debug tab.""" + window = _show(make_window()) + rail = window.axis_readout + assert not hasattr(rail, "jogRequested") + assert not hasattr(rail, "moveAbsRequested") + assert hasattr(rail, "stopAllRequested") + assert hasattr(rail, "enableAllRequested") + + +def test_enable_all_toggles_with_the_current_state(make_window): + from orisys_autocal.motion import AxisState, Snapshot + + window = _show(make_window()) + requested: list[bool] = [] + window.axis_readout.enableAllRequested.connect(requested.append) + + window.axis_readout.update_snapshot( + Snapshot(connected=True, axes=tuple(AxisState(axis=a, enabled=True) for a in range(5))) + ) + window.axis_readout.enable_all_button.click() + assert requested == [False], "enable-all did not become disable-all" + + window.axis_readout.update_snapshot(Snapshot(connected=True, axes=())) + window.axis_readout.enable_all_button.click() + assert requested == [False, True] + + +def test_the_window_refuses_to_shrink_below_the_page(make_window): + window = _show(make_window()) + window.resize(800, 600) + assert window.width() >= window.minimumWidth() + assert window.height() >= window.minimumHeight() + assert window.minimumWidth() >= 1300 + + +def test_the_bus_data_group_starts_collapsed(make_window): + """It is commissioning-only; expanded, it cost the page ~270 px.""" + window = _show(make_window()) + assert not window.bus_data_group.isChecked() + assert not window.bus_data_panel.isVisible() + + +def test_expanding_bus_data_does_not_resize_the_window(make_window): + window = _show(make_window()) + before = (window.width(), window.height()) + window.bus_data_group.setChecked(True) + pump(_app(), 0.1) + assert (window.width(), window.height()) == before + + +def _app(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() + + +def test_the_sequence_tab_shows_the_documented_profile_fields(make_window): + from orisys_autocal.ui.sequence_panel import PROFILE_FIELDS + + window = _show(make_window()) + panel = window.sequence_panel + assert panel.profile_keys() == [key for key, _locale, _kind in PROFILE_FIELDS] + + +def test_the_sequence_panel_exposes_its_three_zones(make_window): + window = _show(make_window()) + panel = window.sequence_panel + assert panel.validation_checks() == [ + "on_surface", + "within_travel", + "contact_confirmable", + ] + assert panel.point_row_count() > 0, "the point list is empty" + assert panel.summary_label.text() + + +def test_every_pending_control_names_the_phase_that_owns_it(make_window): + """A disabled control must say why, or it reads as a broken button.""" + window = _show(make_window()) + panel = window.sequence_panel + assert panel.pending_controls() == [ + "generate", + "simulate", + "dry_run", + "save", + "load", + ] + for name, phase in panel.pending_phases().items(): + button = panel._pending_buttons[name] + assert not button.isEnabled(), f"{name} is usable but not implemented" + assert phase in button.toolTip(), f"{name} does not name its phase" + assert button.text(), f"{name} has no label" + + +def test_the_sequence_tab_hands_out_no_runnable_steps(make_window): + """A runner must not be able to start from points nothing has validated.""" + window = _show(make_window()) + assert window.sequence_panel.planned_steps() == [] + + +def test_point_coordinates_are_generated_not_typed(make_window): + """A typed coordinate is one nothing has checked against the sensor.""" + from orisys_autocal.ui.sequence_panel import _EDITABLE_POINT_COLUMNS + + assert _EDITABLE_POINT_COLUMNS == () + window = _show(make_window()) + panel = window.sequence_panel + # The point list is a real table, so it scrolls instead of clipping rows. + assert panel.table.rowCount() == panel.point_row_count() + assert panel.point_row(0)["x"] == "0.000" + # Every cell is read-only: the table's edit triggers are off, so a generated + # coordinate cannot be typed over. + from PySide6.QtWidgets import QTableWidget + + assert panel.table.editTriggers() == QTableWidget.EditTrigger.NoEditTriggers + + +def test_the_profile_round_trips_through_preferences(make_window): + window = _show(make_window()) + panel = window.sequence_panel + panel.field("pitch_mm").setText("3.5") + panel.field("repeats").setText("4") + window._persist_prefs() + stored = window.prefs["sequence_profile"] + assert stored["pitch_mm"] == 3.5 + assert stored["repeats"] == 4 + + +def test_an_unusable_profile_number_falls_back_rather_than_raising(make_window): + from orisys_autocal.preferences import default_preferences + + window = _show(make_window()) + panel = window.sequence_panel + panel.field("pitch_mm").setText("not a number") + assert panel.profile()["pitch_mm"] == 0.0 + # A blank repeats field must not become zero repeats. + panel.field("repeats").setText("") + assert panel.profile()["repeats"] >= 1 + assert "pitch_mm" in default_preferences()["sequence_profile"] + + +def test_the_stored_profile_is_loaded_into_the_form(make_window): + window = _show(make_window(sequence_profile={"pitch_mm": 7.5, "order": "row"})) + panel = window.sequence_panel + assert panel.profile()["pitch_mm"] == 7.5 + assert panel.profile()["order"] == "row" + + +def test_the_axis_table_shows_seven_columns(make_window): + from orisys_autocal.ui.axis_panel import COLUMNS + + window = _show(make_window()) + assert len(COLUMNS) == 7 + assert window.axis_panel.table.columnCount() == 8 # seven data + actions + + +def test_the_axis_detail_line_carries_what_the_table_dropped(make_window): + """Axis type, accel, feedback and the status word must stay reachable.""" + from orisys_autocal.motion import AxisState, Snapshot + + window = _show(make_window()) + window.axis_panel.set_selected_axis(3) + window.axis_panel.update_snapshot( + Snapshot( + connected=True, + axes=(AxisState(axis=3, enabled=True, atype=65, accel=1234.0, mpos=5.0),), + ) + ) + detail = window.axis_panel._detail_label.text() + assert "65" in detail and "1234" in detail + assert "3" in detail + + +def test_the_axis_detail_follows_the_selected_axis(make_window): + from orisys_autocal.motion import AxisState, Snapshot + + window = _show(make_window()) + window.axis_panel.update_snapshot( + Snapshot( + connected=True, + axes=(AxisState(axis=0, atype=65), AxisState(axis=1, atype=67)), + ) + ) + window.axis_panel.set_selected_axis(0) + assert "65" in window.axis_panel._detail_label.text() + window.axis_panel.set_selected_axis(1) + assert "67" in window.axis_panel._detail_label.text() + + +def test_the_window_defaults_to_the_design_resolution(make_window): + from orisys_autocal.ui.window import DEFAULT_WINDOW_SIZE, MINIMUM_WINDOW_SIZE + + assert DEFAULT_WINDOW_SIZE == (1920, 1080) + assert MINIMUM_WINDOW_SIZE[0] >= 1440 + + +def test_the_default_theme_is_light(make_window): + from orisys_autocal.preferences import DEFAULT_THEME + + assert DEFAULT_THEME == "light" + window = _show(make_window()) + assert window._theme == "light" + + +def test_pausing_the_strip_pauses_the_poller(make_window): + window = _show(make_window()) + window.status_strip.pause_button.setChecked(True) + window._on_pause_polling() + assert window.poller.paused + window.status_strip.pause_button.setChecked(False) + window._on_pause_polling() + assert not window.poller.paused + + +def test_the_diagnostics_footer_does_not_move_the_layout(make_window): + window = _show(make_window()) + splitters = window.main_splitter.sizes() + window._set_diagnostics(True) + window._set_diagnostics(False) + assert window.main_splitter.sizes() == splitters + + +def test_splitter_sizes_round_trip_through_preferences(make_window): + window = _show(make_window()) + window.main_splitter.setSizes([340, 1500]) + window._persist_prefs() + stored = window.prefs["main_splitter_sizes"] + assert len(stored) == 2 + assert stored[1] > stored[0] + + +def test_the_rail_is_a_single_scrollable_column(make_window): + """Two thin rails bracketing the workspace cost it 240 px at every size.""" + from orisys_autocal.ui.window import MAIN_SPLITTER_SIZES + + assert len(MAIN_SPLITTER_SIZES) == 2, "the layout is back to three columns" + window = _show(make_window()) + assert window.main_splitter.count() == 2 + assert window.left_rail.widget() is window.rail_content + # The readout lives in the rail now, not in a rail of its own. + assert window.rail_content.findChild(type(window.axis_readout)) is window.axis_readout + + +def test_the_rail_scrolls_rather_than_clipping_its_controls(make_window): + """At 900 px high the three rail groups do not fit; the rail must scroll.""" + window = _show(make_window(), (1440, 900)) + viewport = window.left_rail.viewport().height() + content = window.rail_content.sizeHint().height() + scroll = window.left_rail.verticalScrollBar() + assert content <= viewport or scroll.maximum() > 0, ( + f"rail content is {content}px in a {viewport}px viewport and cannot scroll" + ) + + +# --------------------------------------------------------------- run controls + + +def test_the_run_bar_is_visible_from_every_tab(make_window): + """Abort must never require finding the tab that started the run.""" + window = _show(make_window()) + for key in window.workspace.page_keys(): + window.workspace.set_current_key(key) + assert window.run_bar.isVisible(), f"the run bar hid on tab {key}" + + +def test_run_is_wired_and_refuses_by_naming_its_phase(make_window): + window = _show(make_window()) + window.run_bar.run_button.click() + assert "P7" in window.status_strip.action_label.text() + + +def test_abort_stops_the_axes_and_is_enabled_once_a_run_exists(make_window): + window = _show(make_window()) + assert not window.run_bar.abort_button.isEnabled() + + requested = [] + window.run_bar.abortRequested.connect(lambda: requested.append(True)) + window.run_bar.set_run(True) + assert window.run_bar.abort_button.isEnabled() + window.run_bar.abort_button.click() + assert requested == [True] + assert window.run_bar.phase == "failed" + + +def test_the_run_bar_shows_progress_and_timing(make_window): + window = _show(make_window()) + window.run_bar.set_progress(7, 40) + assert "7" in window.run_bar.step_label.text() + assert "40" in window.run_bar.step_label.text() + assert window.run_bar.progress.maximum() == 40 + assert window.run_bar.progress.value() == 7 + + window.run_bar.set_timing(125.0, 300.0) + assert "2:05" in window.run_bar.time_label.text() + # An unknown estimate must say so rather than showing a wrong number. + window.run_bar.set_timing(125.0, None) + assert "2:05" in window.run_bar.time_label.text() + + +# ---------------------------------------------------------------- poll health + + +def test_the_health_chip_reports_the_age_of_the_last_reading(make_window): + from orisys_autocal.motion import Snapshot + + window = _show(make_window()) + strip = window.status_strip + strip.set_snapshot(Snapshot(connected=True)) + assert strip.chip_health.text() + assert strip.chip_health._tone == "ok" + + +def test_the_health_chip_ages_when_the_poll_stops(make_window): + """A stalled poll must not keep looking healthy.""" + from orisys_autocal.motion import Snapshot + + window = _show(make_window()) + strip = window.status_strip + strip.set_poll_interval_ms(100) + strip.set_snapshot(Snapshot(connected=True)) + strip._last_snapshot_at -= 1.5 # fifteen intervals ago + strip.refresh_health() + assert strip.chip_health._tone == "error", strip.chip_health.text() + + +def test_the_health_chip_says_paused_rather_than_ageing(make_window): + from orisys_autocal.motion import Snapshot + + window = _show(make_window()) + strip = window.status_strip + strip.set_snapshot(Snapshot(connected=True)) + strip.set_paused(True) + strip.refresh_health() + assert strip.chip_health._tone == "muted" + assert strip.chip_health.text() != "" + + +def test_the_status_bar_uses_its_own_theme_token(make_window): + """The brand navy reads well as a header and heavy as a footer.""" + from orisys_app_shared.theme import load_colors + + for theme in ("light", "dark"): + colours = load_colors(theme) + assert "status_bar_bg" in colours, theme + assert "status_bar_text" in colours, theme + window = _show(make_window()) + assert "QStatusBar" in window.statusBar().styleSheet() + + +def test_language_switch_relabels_the_new_shell(make_window): + from orisys_app_shared.i18n import t + + window = _show(make_window(language="zh_CN")) + zh_tabs = [window.workspace.tabText(i) for i in range(window.workspace.count())] + window._set_language("en_US") + assert window.status_strip.title.text() == t("status.motion") + assert window.run_bar.title.text() == t("run.title") + en_tabs = [window.workspace.tabText(i) for i in range(window.workspace.count())] + assert en_tabs != zh_tabs + assert all(en_tabs) diff --git a/apps/autocal/tests/test_locales_autocal.py b/apps/autocal/tests/test_locales_autocal.py new file mode 100644 index 0000000..f82abf8 --- /dev/null +++ b/apps/autocal/tests/test_locales_autocal.py @@ -0,0 +1,88 @@ +"""Locale files: same keys everywhere, and a real fallback. + +``Translator`` returns the key itself when a string is missing, which is silent +in production — a label would simply read ``axes.nope``. The studio suite guards +this by comparing key sets; the same guard applies here. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from orisys_autocal.preferences import load_preferences + +LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales" +LANGUAGES = ("zh_CN", "en_US") + + +def _load(language: str) -> dict: + return json.loads((LOCALES / f"{language}.json").read_text(encoding="utf-8")) + + +def _flatten(node: dict, prefix: str = "") -> set[str]: + keys: set[str] = set() + for key, value in node.items(): + path = f"{prefix}{key}" + if isinstance(value, dict): + keys |= _flatten(value, f"{path}.") + else: + keys.add(path) + return keys + + +def test_every_locale_file_exists(): + for language in LANGUAGES: + assert (LOCALES / f"{language}.json").is_file(), language + + +def test_locales_have_identical_key_sets(): + reference = _flatten(_load("zh_CN")) + for language in LANGUAGES[1:]: + other = _flatten(_load(language)) + assert other == reference, { + f"{language}_only": sorted(other - reference), + f"zh_CN_only": sorted(reference - other), + } + + +def test_no_translation_is_empty_where_a_label_is_expected(): + """A blank string renders as an empty widget, which looks like a bug.""" + for language in LANGUAGES: + for key, value in _walk(_load(language)): + assert str(value).strip(), f"{language}: {key} is blank" + + +def test_the_product_name_is_localised(): + """The window title is Chinese in zh_CN and English in en_US.""" + assert _load("zh_CN")["app"]["title"] != _load("en_US")["app"]["title"] + + +@pytest.mark.parametrize("language", LANGUAGES) +def test_every_locale_loads_through_the_translator(language): + from orisys_app_shared.i18n import Translator + + translator = Translator(LOCALES, locale=language) + for key in sorted(_flatten(_load(language))): + assert translator.t(key) != key, f"{language}: {key} did not resolve" + + +def test_the_default_preferences_file_matches_the_schema_shape(): + """A shipped default that names a key the app does not have is dead weight.""" + shipped = json.loads( + (LOCALES.parent / "preferences.default.json").read_text(encoding="utf-8") + ) + schema = load_preferences() + for key in shipped: + assert key in schema, f"preferences.default.json has an unknown key: {key}" + + +def _walk(node, prefix: str = ""): + for key, value in node.items(): + path = f"{prefix}{key}" + if isinstance(value, dict): + yield from _walk(value, f"{path}.") + else: + yield path, value diff --git a/apps/autocal/tests/test_motion_service.py b/apps/autocal/tests/test_motion_service.py new file mode 100644 index 0000000..b91b0df --- /dev/null +++ b/apps/autocal/tests/test_motion_service.py @@ -0,0 +1,328 @@ +"""Guard and state-machine behaviour of the motion service. + +Driven against the simulated platform, so every assertion here is about AutoCal's +own logic: what it refuses, what it reads, and when. No test in this file touches +a controller or a DLL. +""" + +from __future__ import annotations + +import time + +import pytest + +from orisys_autocal.motion import ( + INIT_FAILED, + INIT_OK, + INIT_RUNNING, + AxisParams, + ConnectionSpec, + DemoTransport, + MotionService, +) + + +@pytest.fixture +def service(): + return MotionService(DemoTransport()) + + +def _initialise(service, timeout: float = 5.0) -> None: + """Download a BAS program and wait for the simulated bus to report ready.""" + service.download_bas(__file__, 1) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline and service.bus.init_status != INIT_OK: + service.poll([0]) + time.sleep(0.05) + + +# ----------------------------------------------------------------- connection + + +def test_a_fresh_service_is_disconnected_and_touches_nothing(service): + assert not service.connected + assert not service.bas_loaded + assert service.poll([0]).connected is False + + +def test_connect_reports_success_and_an_unknown_bus_without_a_bas(service): + result = service.connect(ConnectionSpec(mode="demo")) + assert result.ok + assert service.connected + # No BAS program has run, so the bus type must not be guessed at. + snapshot = service.poll([0]) + assert snapshot.bus.bus_type == "unknown" + assert not snapshot.bus.initialized + + +def test_connect_failure_is_reported_without_raising(service): + result = service.connect(ConnectionSpec(mode="eth", ip="127.0.0.1")) + assert not result.ok + assert not service.connected + + +def test_disconnect_clears_bus_state(service): + service.connect(ConnectionSpec(mode="demo")) + service.disconnect() + assert not service.connected + assert service.bus.bus_type == "unknown" + + +# ---------------------------------------------------------------- bus init + + +def test_download_requires_a_real_file(service): + service.connect(ConnectionSpec(mode="demo")) + assert not service.download_bas("", 1).ok + assert not service.download_bas(r"D:\nope\missing.bas", 1).ok + + +def test_download_needs_a_connection(service): + assert not service.download_bas(__file__, 1).ok + + +def test_download_then_poll_reaches_initialised(service): + service.connect(ConnectionSpec(mode="demo")) + assert service.download_bas(__file__, 1).ok + _initialise(service) + assert service.bus.init_status == INIT_OK + assert service.bus.bus_type == "ecat" + assert service.bus.total_axes == 5 + assert service.bus.node_count == 5 + + +def test_bus_is_running_immediately_after_download(service): + service.connect(ConnectionSpec(mode="demo")) + service.download_bas(__file__, 1) + assert service.bus.init_status == INIT_RUNNING + + +def test_reinitialise_is_refused_while_already_running(service): + service.connect(ConnectionSpec(mode="demo")) + service.download_bas(__file__, 1) + assert not service.reinit_bus().ok + + +def test_reinitialise_is_refused_without_a_bas_program(service): + service.connect(ConnectionSpec(mode="demo")) + result = service.reinit_bus() + assert not result.ok + assert "BAS" in result.message or "Bas" in result.message + + +def test_init_timeout_is_reported_rather_than_raising(): + """A controller that never publishes its globals must time out, not hang.""" + + class SilentTransport(DemoTransport): + def user_var(self, name): + from orisys_app_shared.zmotion import ZAuxError + + raise ZAuxError(30002, f"{name} is not defined") + + service = MotionService(SilentTransport()) + service._init_timeout = 0.05 # noqa: SLF001 - shortening the vendor window + service.connect(ConnectionSpec(mode="demo")) + service.download_bas(__file__, 1) + time.sleep(0.1) + snapshot = service.poll([0]) + assert snapshot.bus.init_status == INIT_FAILED + assert snapshot.bus.message # carries the ZDevelop hint, not an empty string + + +# ------------------------------------------------------------------- guards + + +def test_motion_is_refused_before_the_bus_is_initialised(service): + service.connect(ConnectionSpec(mode="demo")) + for attempt in ( + service.jog(0, True), + service.move_abs(0, 100.0), + service.move_rel(0, 10.0), + service.home(0), + ): + assert not attempt.ok + assert attempt.message + + +def test_motion_is_refused_when_not_connected(service): + assert not service.jog(0, True).ok + assert not service.move_abs(0, 1.0).ok + assert not service.stop(0).ok + + +def test_motion_on_an_unknown_axis_is_refused(service): + service.connect(ConnectionSpec(mode="demo")) + _initialise(service) + result = service.jog(99, True) + assert not result.ok + assert "99" in result.message or "0-4" in result.message + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) +def test_a_non_finite_target_is_refused(service, bad): + service.connect(ConnectionSpec(mode="demo")) + _initialise(service) + result = service.move_abs(0, bad) + assert not result.ok + assert result.message + + +def test_home_needs_a_configured_mode(service): + """0 is not a valid homing mode; inheriting one would be worse than saying so.""" + service.connect(ConnectionSpec(mode="demo")) + _initialise(service) + result = service.home(0) + assert not result.ok + assert "0" in result.message + + +def test_home_runs_once_a_mode_is_set(service): + service.connect(ConnectionSpec(mode="demo")) + _initialise(service) + service.set_axis_params(0, AxisParams(home_mode=17)) + assert service.home(0).ok + + +def test_home_is_refused_on_rtex(service): + """The vendor example refuses RTEX homing; so do we.""" + + class RtexTransport(DemoTransport): + def user_var(self, name): + return 1.0 if name == "BUS_TYPE" else super().user_var(name) + + rtex = MotionService(RtexTransport()) + rtex.connect(ConnectionSpec(mode="demo")) + _initialise(rtex) + assert rtex.bus.bus_type == "rtex" + result = rtex.home(0) + assert not result.ok + assert result.message + + +# ------------------------------------------------------------------- motion + + +def test_jog_and_stop_move_the_state_machine(service): + service.connect(ConnectionSpec(mode="demo")) + _initialise(service) + assert service.jog(0, True).ok + state = service.poll([0]).axis(0) + assert state is not None and not state.idle + + assert service.stop(0).ok + state = service.poll([0]).axis(0) + assert state is not None and state.idle + + +def test_absolute_and_relative_moves_change_the_command_position(service): + service.connect(ConnectionSpec(mode="demo")) + _initialise(service) + assert service.move_abs(1, 250.0).ok + assert service.poll([1]).axis(1).dpos == pytest.approx(250.0) + assert service.move_rel(1, -50.0).ok + assert service.poll([1]).axis(1).dpos == pytest.approx(200.0) + + +def test_set_axis_params_writes_and_remembers(service): + service.connect(ConnectionSpec(mode="demo")) + _initialise(service) + params = AxisParams(units=5.0, speed=200.0, accel=2000.0) + assert service.set_axis_params(2, params).ok + assert service.params(2) == params + assert service.to_mm(2, 1000.0) == pytest.approx(1.0) + + +def test_enable_toggles_the_axis(service): + service.connect(ConnectionSpec(mode="demo")) + _initialise(service) + assert service.set_axis_enable(3, False).ok + assert service.poll([3]).axis(3).enabled is False + assert service.set_axis_enable(3, True).ok + assert service.poll([3]).axis(3).enabled is True + + +def test_stop_all_covers_every_axis(service): + service.connect(ConnectionSpec(mode="demo")) + _initialise(service) + results = service.stop_all() + assert len(results) == service.axis_count + assert all(result.ok for result in results) + + +def test_clear_alarm_reaches_the_drive(service): + service.connect(ConnectionSpec(mode="demo")) + _initialise(service) + assert service.clear_alarm(0).ok + + +# ----------------------------------------------------------------- bus data + + +def test_sdo_round_trips_on_ethercat(service): + service.connect(ConnectionSpec(mode="demo")) + _initialise(service) + assert service.sdo_write(0, 0x6040, 0, 3, 6).ok + result, value = service.sdo_read(0, 0x6040, 0, 3) + assert result.ok + assert value == 6 + + +def test_sdo_is_refused_on_rtex(service): + class RtexTransport(DemoTransport): + def user_var(self, name): + return 1.0 if name == "BUS_TYPE" else super().user_var(name) + + rtex = MotionService(RtexTransport()) + rtex.connect(ConnectionSpec(mode="demo")) + _initialise(rtex) + result, _value = rtex.sdo_read(0, 0x6040, 0, 3) + assert not result.ok + + +def test_rtex_is_refused_on_ethercat(service): + service.connect(ConnectionSpec(mode="demo")) + _initialise(service) + result, _value = service.rtex_read(0, 1) + assert not result.ok + + +def test_bus_data_is_refused_before_the_bus_type_is_known(service): + service.connect(ConnectionSpec(mode="demo")) + result, _value = service.sdo_read(0, 0x6040, 0, 3) + assert not result.ok + + +# ------------------------------------------------------------------- polling + + +def test_polling_reports_every_requested_axis(service): + service.connect(ConnectionSpec(mode="demo")) + snapshot = service.poll([0, 1, 2, 3, 4]) + assert [state.axis for state in snapshot.axes] == [0, 1, 2, 3, 4] + assert all(state.read_ok for state in snapshot.axes) + + +def test_a_read_failure_is_marked_not_silently_zeroed(): + """A failed read must not look like a position of zero.""" + + class FailingReads(DemoTransport): + def get_dpos(self, axis): + from orisys_app_shared.zmotion import ZAuxError + + raise ZAuxError(30000, "no response") + + service = MotionService(FailingReads()) + service.connect(ConnectionSpec(mode="demo")) + snapshot = service.poll([0]) + state = snapshot.axis(0) + assert state is not None + assert state.read_ok is False + assert state.error + + +def test_language_switches_the_refusal_text(service): + assert not service.jog(0, True).ok + zh = service.jog(0, True).message + service.set_language("en_US") + en = service.jog(0, True).message + assert zh != en diff --git a/apps/autocal/tests/test_panel_smoke.py b/apps/autocal/tests/test_panel_smoke.py new file mode 100644 index 0000000..7b6fb51 --- /dev/null +++ b/apps/autocal/tests/test_panel_smoke.py @@ -0,0 +1,331 @@ +"""Offscreen UI smoke tests: construction, layout, gating, and preferences. + +The window is built for real, with the simulated platform standing in for the +controller. Nothing here needs a display or hardware. +""" + +from __future__ import annotations + +import pytest + +from conftest import pump, visible_descendants + +RESOLUTIONS = ((1440, 900), (1600, 900), (1920, 1080)) + + +def _show(window, size=(1440, 880)): + from PySide6.QtWidgets import QApplication + + window.resize(*size) + window.show() + QApplication.processEvents() + return window + + +def _enabled(widget) -> bool: + """Whether ``widget`` is effectively enabled, shown or not. + + A plain ``isEnabled()`` is False for every child of a window that has not + been shown, which makes an assertion about gating depend on whether the + test happened to realise the window. ``isEnabledTo`` answers the question + the tests actually mean: would a user be able to operate this control? + """ + return widget.isEnabledTo(widget.window()) + + +# ------------------------------------------------------------------ structure + + +@pytest.mark.parametrize(("theme", "language"), [ + ("dark", "zh_CN"), + ("light", "zh_CN"), + ("dark", "en_US"), + ("light", "en_US"), +]) +def test_the_window_builds_in_every_theme_and_language(make_window, theme, language): + window = _show(make_window(theme=theme, language=language)) + assert window.windowTitle() + assert window.axis_panel.table.rowCount() == 5 + + +def test_the_product_name_is_localised_in_the_window_title(make_window): + window = _show(make_window(language="zh_CN")) + zh = window.windowTitle() + window._set_language("en_US") + assert window.windowTitle() != zh + + +def test_every_axis_row_has_a_labelled_control(make_window): + window = _show(make_window()) + panel = window.axis_panel + for row in range(panel.axis_count): + widgets = panel._rows[row] + for key in ("enable_button", "fwd_button", "rev_button", "stop_button"): + assert widgets[key].text().strip(), f"row {row} {key} has no label" + + +def test_the_table_shows_one_column_per_documented_quantity(make_window): + from orisys_app_shared.i18n import t + + from orisys_autocal.ui.axis_panel import COLUMNS + + window = _show(make_window()) + headers = [ + window.axis_panel.table.horizontalHeaderItem(index).text() + for index in range(window.axis_panel.table.columnCount()) + ] + assert headers == [t(key) for key in COLUMNS] + [t("axes.controls")] + + +@pytest.mark.parametrize("size", RESOLUTIONS) +def test_every_axis_row_is_reachable(make_window, size): + """No drive may be hidden by the splitter. + + The panel asks for three rows without scrolling; at the default window size + all five must be visible, because a clipped fifth row on a five-axis platform + reads as a missing drive rather than as a scrollbar. If the panel is ever + squeezed below that, the table has to scroll rather than silently cut a row. + """ + window = _show(make_window(), size) + table = window.axis_panel.table + rows_height = sum( + table.rowHeight(row) for row in range(window.axis_panel.axis_count) + ) + header = table.horizontalHeader().height() + available = table.viewport().height() + + assert available >= rows_height or table.verticalScrollBar().maximum() > 0, ( + f"{size}: rows need {rows_height}px, viewport has {available}px " + f"(header {header}px) and it cannot scroll" + ) + + +@pytest.mark.parametrize("size", RESOLUTIONS) +def test_no_visible_child_escapes_its_parent(make_window, size): + window = _show(make_window(), size) + oversize = [] + for child in visible_descendants(window): + if _inside_scroll_area(child): + # A scroll area's content is *meant* to exceed its viewport: the rail + # scrolls instead of squeezing its controls into an unreadable column. + 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( + f"{type(child).__name__} {geometry.width()}x{geometry.height()}" + f" > parent {parent.width()}x{parent.height()}" + ) + assert not oversize, oversize + + +def _inside_scroll_area(widget) -> bool: + """Whether ``widget`` is content of a QAbstractScrollArea, not a laid-out child.""" + from PySide6.QtWidgets import QAbstractScrollArea + + parent = widget.parentWidget() + while parent is not None: + if isinstance(parent, QAbstractScrollArea): + return True + parent = parent.parentWidget() + return False + + +# ----------------------------------------------------------------------- gating + + +def test_motion_controls_start_disabled_without_a_bus(make_window): + window = _show(make_window()) + assert not _enabled(window.axis_panel._rows[0]["fwd_button"]) + assert not _enabled(window.axis_panel._rows[0]["rev_button"]) + + +def test_bus_data_panels_follow_the_reported_bus_type(make_window): + window = _show(make_window()) + window.bus_data_panel.set_connected(True) + + window.bus_data_panel.set_bus_type("ecat") + assert _enabled(window.bus_data_panel.ecat_box) + assert not _enabled(window.bus_data_panel.rtex_box) + + window.bus_data_panel.set_bus_type("rtex") + assert _enabled(window.bus_data_panel.rtex_box) + assert not _enabled(window.bus_data_panel.ecat_box) + + window.bus_data_panel.set_bus_type("unknown") + assert not _enabled(window.bus_data_panel.ecat_box) + assert not _enabled(window.bus_data_panel.rtex_box) + + +def test_bus_data_panels_are_disabled_while_disconnected(make_window): + window = _show(make_window()) + window.bus_data_panel.set_bus_type("ecat") + window.bus_data_panel.set_connected(False) + assert not _enabled(window.bus_data_panel.ecat_box) + + +def test_axis_rows_dim_when_the_bus_reports_fewer_drives(make_window): + window = _show(make_window()) + window.axis_panel.set_connected(True) + window.axis_panel.set_axis_count(3) + assert _enabled(window.axis_panel._rows[2]["enable_button"]) + assert not _enabled(window.axis_panel._rows[3]["enable_button"]) + + +def test_unknown_connection_modes_report_instead_of_pretending(make_window): + from orisys_app_shared.i18n import t + + window = _show(make_window()) + panel = window.connection_panel + index = panel.mode_combo.findData("com") + panel.mode_combo.setCurrentIndex(index) + panel._emit_connect() + assert panel.status_label.text() != t("connection.state_open") + + +# ------------------------------------------------------------------- actions + + +def test_the_selected_axis_follows_a_table_click(make_window): + window = _show(make_window()) + window.axis_panel._on_cell_clicked(3, 0) + assert window.axis_panel.selected_axis == 3 + assert window.axis_panel.axis_index_label.text() == "3" + + +def test_blank_numeric_fields_fall_back_to_defaults(make_window): + from orisys_autocal.motion import DEFAULT_UNITS + + window = _show(make_window()) + panel = window.axis_panel + panel.units_edit.setText("") + assert panel.current_params().units == DEFAULT_UNITS + panel.units_edit.setText("not a number") + assert panel.current_params().units == DEFAULT_UNITS + + +def test_a_blank_target_does_not_emit_a_move(make_window): + window = _show(make_window()) + emitted = [] + window.axis_panel.moveAbsRequested.connect(lambda axis, value: emitted.append(value)) + window.axis_panel.target_edit.setText("") + window.axis_panel._on_move_abs() + assert emitted == [] + + +def test_applying_parameters_persists_them_in_the_panel(make_window): + from orisys_autocal.motion import AxisParams + + window = _show(make_window()) + panel = window.axis_panel + panel.set_selected_axis(1) + panel.units_edit.setText("5") + panel.speed_edit.setText("250") + panel.accel_edit.setText("2500") + panel._on_apply() + stored = panel.stored_params()[1] + assert isinstance(stored, AxisParams) + assert stored.units == 5.0 + + +# --------------------------------------------------------------- preferences + + +def test_the_window_geometry_round_trips_through_preferences(make_window): + # Above the enforced minimum (1400x860): a smaller request would be clamped, + # which tests Qt's constraint rather than the preference round-trip. + window = _show(make_window(), (1520, 920)) + window._persist_prefs() + geometry = window.prefs["window_geometry"] + assert len(geometry) == 4 + assert geometry[2] == pytest.approx(1520, abs=5) + assert geometry[3] == pytest.approx(920, abs=5) + + +def test_a_malformed_geometry_is_ignored_rather_than_crashing(make_window): + window = _show(make_window(window_geometry=["nonsense"])) + assert window.width() > 0 + + +def test_axis_parameters_survive_a_persist_reload(make_window): + from orisys_autocal import preferences as prefs_mod + + window = _show(make_window()) + window.axis_panel.set_selected_axis(2) + window.axis_panel.units_edit.setText("7") + window.axis_panel._on_apply() + window._persist_prefs() + + reloaded = prefs_mod.load_preferences() + assert reloaded["axis_params"]["2"]["units"] == 7.0 + + +def test_the_bus_path_is_remembered(make_window): + window = _show(make_window()) + window.bus_panel.set_bas_path(r"D:\somewhere\ECAT.bas") + window._persist_prefs() + assert window.prefs["bas_path"] == r"D:\somewhere\ECAT.bas" + + +def test_sdo_fields_are_remembered(make_window): + window = _show(make_window()) + panel = window.bus_data_panel + panel.node_edit.setText("2") + panel.index_edit.setText("24608") + panel.subindex_edit.setText("1") + window._persist_prefs() + assert window.prefs["sdo_last"]["node"] == 2 + assert window.prefs["sdo_last"]["index"] == 24608 + + +# --------------------------------------------------------------- demo session + + +def test_a_demo_session_fills_the_table_and_gates_motion(make_window, qt_app): + """End to end through the real signal path: connect, initialise, jog.""" + from orisys_autocal.motion import ConnectionSpec + + window = _show(make_window()) + window.connect_controller(ConnectionSpec(mode="demo"), label="connect") + assert _wait_for(lambda: window._last_snapshot.connected, qt_app) + + # Download the shipped ECAT.bas, then wait for the simulated scan. + window.bus_panel.set_bas_path(str(_shipped_bas("ECAT.bas"))) + window._on_download_bas() + assert _wait_for(lambda: window._last_snapshot.bus.init_status == 1, qt_app, timeout=8.0) + + assert window._last_snapshot.bus.bus_type == "ecat" + assert _enabled(window.bus_data_panel.ecat_box) + assert _enabled(window.axis_panel._rows[0]["fwd_button"]) + # ATYPE is no longer a table column — the seven-column table shows position + # and status, and the drive's full frame is on the selected-axis detail line. + window.axis_panel.set_selected_axis(0) + assert "65" in window.axis_panel._detail_label.text() + pump(qt_app, 0.2) + + +def test_closing_the_window_stops_the_poller(make_window, qt_app): + window = _show(make_window()) + window.close() + pump(qt_app, 0.2) + assert not window.poller._thread.isRunning() + + +def _shipped_bas(name: str): + from orisys_autocal.ui.bus_panel import default_bas_dir + + return default_bas_dir() / name + + +def _wait_for(predicate, app, timeout: float = 5.0) -> bool: + import time + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + app.processEvents() + if predicate(): + return True + time.sleep(0.02) + return False diff --git a/apps/autocal/tests/test_poller.py b/apps/autocal/tests/test_poller.py new file mode 100644 index 0000000..73fe5c5 --- /dev/null +++ b/apps/autocal/tests/test_poller.py @@ -0,0 +1,233 @@ +"""The poller thread: serialisation, failure isolation, and clean teardown. + +The two things that must never happen are a controller call on the GUI thread +and two controller calls at once. Both are asserted here against a transport +that records how it was used. +""" + +from __future__ import annotations + +import threading +import time + +import pytest + +from orisys_autocal.motion import ConnectionSpec, DemoTransport, MotionPoller, MotionService + + +class RecordingTransport(DemoTransport): + """A *connected* demo platform that records thread and overlap of every call. + + Connected on purpose: the service polls only ``BUS_TYPE`` and ``Bus_InitStatus`` + without a BAS program, so an unconnected transport would keep every read + short and nothing could be observed. A ``_concurrent`` guard asserts no two + calls ever overlap rather than merely asserting the maximum sum of started + calls, which would be trivially satisfied. + """ + + def __init__( + self, + *, + delay: float = 0.0, + unexpected_on: set[str] | None = None, + ) -> None: + super().__init__() + self.delay = delay + self.unexpected_on = unexpected_on or set() + self.thread_ids: set[int] = set() + self.max_concurrent = 0 + self.overlaps: list[str] = [] + self.calls: list[str] = [] + self._lock = threading.Lock() + self._concurrent = 0 + # A fake "BAS program has run": the bus reports EtherCAT and ready. + self._init_started = None + self._preloaded = True + + def open(self, spec) -> None: + super().open(ConnectionSpec(mode="demo")) + self._bas_loaded = True + + def user_var(self, name: str) -> float: + if name == "BUS_TYPE": + return 0.0 + if name == "Bus_InitStatus": + return 1.0 + if name == "Bus_TotalAxisnum": + return 5.0 + return super().user_var(name) + + def _enter(self, name: str) -> None: + with self._lock: + self._concurrent += 1 + if self._concurrent > 1: + self.overlaps.append(name) + self.max_concurrent = max(self.max_concurrent, self._concurrent) + self.calls.append(name) + self.thread_ids.add(threading.get_ident()) + if self.delay: + time.sleep(self.delay) + if name in self.unexpected_on: + raise RuntimeError(f"simulated unexpected fault in {name}") + + def _exit(self) -> None: + with self._lock: + self._concurrent -= 1 + + def get_dpos(self, axis: int) -> float: + self._enter("get_dpos") + try: + return super().get_dpos(axis) + finally: + self._exit() + + def get_mpos(self, axis: int) -> float: + self._enter("get_mpos") + try: + return super().get_mpos(axis) + finally: + self._exit() + + def get_axis_status(self, axis: int) -> int: + self._enter("get_axis_status") + try: + return super().get_axis_status(axis) + finally: + self._exit() + + def get_if_idle(self, axis: int) -> int: + self._enter("get_if_idle") + try: + return super().get_if_idle(axis) + finally: + self._exit() + + +def _connected_poller(transport: RecordingTransport, *, axes=(0,)) -> MotionPoller: + service = MotionService(transport) + assert service.connect(ConnectionSpec(mode="demo")).ok + return MotionPoller(service, axes=list(axes)) + + +@pytest.fixture +def qt_app_session(qt_app): + return qt_app + +def _drain(app, seconds: float = 1.0) -> None: + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + app.processEvents() + time.sleep(0.005) + + +def _wait_for(predicate, app, timeout: float = 5.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + app.processEvents() + if predicate(): + return True + time.sleep(0.01) + return False + + +def test_completed_commands_are_reported_and_none_are_lost(qt_app): + transport = RecordingTransport() + poller = _connected_poller(transport) + try: + repo = {"finished": [], "failed": []} + poller.commandFinished.connect(lambda token, result: repo["finished"].append(token)) + poller.commandFailed.connect(lambda token, message: repo["failed"].append(token)) + + tokens = [poller.submit(lambda service: None) for _ in range(5)] + assert _wait_for(lambda: len(repo["finished"]) == 5, qt_app) + assert sorted(repo["finished"]) == sorted(tokens) + assert repo["failed"] == [] + finally: + poller.shutdown() + + +def test_every_controller_call_happens_on_one_worker_thread(qt_app): + transport = RecordingTransport() + poller = _connected_poller(transport, axes=(0, 1)) + try: + assert _wait_for(lambda: "get_dpos" in transport.calls, qt_app) + poller.submit(lambda service: None) + _drain(qt_app, 0.3) + assert len(transport.thread_ids) == 1, transport.thread_ids + assert threading.get_ident() not in transport.thread_ids, ( + "a controller call ran on the test's (GUI) thread" + ) + finally: + poller.shutdown() + + +def test_two_controller_calls_never_overlap(qt_app): + """A slow poll plus queued commands must still serialise.""" + transport = RecordingTransport(delay=0.02) + poller = _connected_poller(transport) + try: + for _ in range(4): + poller.submit(lambda service: None) + _drain(qt_app, 0.8) + assert transport.max_concurrent == 1, ( + f"{transport.max_concurrent} controller calls overlapped at once" + ) + assert not transport.overlaps, transport.overlaps + finally: + poller.shutdown() + + +def test_an_unexpected_fault_is_reported_and_does_not_kill_the_poller(qt_app): + """A non-``ZAuxError`` from inside a command must not stop the timer.""" + transport = RecordingTransport(unexpected_on={"get_dpos"}) + poller = _connected_poller(transport) + try: + failures = [] + poller.commandFailed.connect(lambda token, message: failures.append((token, message))) + + # The submitted action asks for the position directly, so the fault + # surfaces from the command rather than being absorbed by a poll. + poller.submit(lambda service: service.transport.get_dpos(0)) + assert _wait_for(lambda: bool(failures), qt_app) + assert failures[0][1] + + # The poller is still alive: later snapshots keep arriving. + before = len(transport.calls) + _drain(qt_app, 0.4) + assert len(transport.calls) > before + finally: + poller.shutdown() + + +def test_shutdown_stops_the_thread_and_is_idempotent(qt_app): + poller = _connected_poller(RecordingTransport()) + _drain(qt_app, 0.2) + poller.shutdown(timeout_ms=3000) + poller.shutdown(timeout_ms=3000) # must not raise or hang + + +def test_a_refused_command_still_arrives_with_its_reason(qt_app): + """An unconnected service refuses in the guard, without touching the DLL.""" + poller = MotionPoller(MotionService(RecordingTransport()), axes=[0]) + try: + results = [] + poller.commandFinished.connect(lambda token, result: results.append(result)) + poller.submit(lambda service: service.jog(0, True)) + assert _wait_for(lambda: bool(results), qt_app) + assert results[0].ok is False + assert results[0].message + finally: + poller.shutdown() + + +def test_connect_and_disconnect_round_trip_through_the_poller(qt_app): + poller = MotionPoller(MotionService(RecordingTransport()), axes=[0]) + try: + states = [] + poller.connectionChanged.connect(states.append) + poller.connect_controller(ConnectionSpec(mode="demo")) + assert _wait_for(lambda: poller.service.connected, qt_app) + poller.disconnect_controller() + assert _wait_for(lambda: not poller.service.connected, qt_app) + finally: + poller.shutdown() diff --git a/apps/autocal/tests/test_units.py b/apps/autocal/tests/test_units.py new file mode 100644 index 0000000..4bf5161 --- /dev/null +++ b/apps/autocal/tests/test_units.py @@ -0,0 +1,73 @@ +"""Unit-to-millimetre conversion. + +The reference program carries the bench measurement in a comment:: + + # è½´0(X)/è½´1(Y) 实测: UNITS=10 æ—¶ 1000用户å�•ä½� = 2mm → æ¯�毫米 5000 脉冲 + # 当é‡�5 → 1000å�•ä½�=1mm,ä¸�是100å�•ä½�=1mm + +These are the assertions that keep that comment honest. +""" + +from __future__ import annotations + +import math + +import pytest + +from orisys_autocal.motion.units import ( + PULSES_PER_MM, + mm_to_user_units, + safe_float, + user_units_to_mm, +) + + +@pytest.mark.parametrize( + ("units", "user_units", "expected_mm"), + [ + (10.0, 1000.0, 2.0), # the measured case from the vendor comment + (5.0, 1000.0, 1.0), # ...and the second one + (10.0, 0.0, 0.0), + (10.0, 5000.0, 10.0), + (10.0, -1000.0, -2.0), # negative travel must stay negative + ], +) +def test_conversion_matches_the_bench_measurement(units, user_units, expected_mm): + assert user_units_to_mm(user_units, units=units) == pytest.approx(expected_mm) + + +def test_default_units_is_the_platform_value(): + assert user_units_to_mm(1000.0) == pytest.approx(2.0) + assert PULSES_PER_MM == 5000.0 + + +def test_conversion_round_trips(): + for units in (1.0, 5.0, 10.0, 37.5): + millimetres = user_units_to_mm(1234.5, units=units) + assert mm_to_user_units(millimetres, units=units) == pytest.approx(1234.5) + + +def test_a_zero_or_negative_scale_is_not_a_number(): + """A bad constant must be visibly unusable, not a plausible number.""" + assert math.isnan(user_units_to_mm(1000.0, pulses_per_mm=0.0)) + assert math.isnan(user_units_to_mm(1000.0, pulses_per_mm=-1.0)) + assert math.isnan(mm_to_user_units(1.0, units=0.0)) + + +@pytest.mark.parametrize( + "text", + ["", " ", "abc", "1,5", None, "nan", "inf", "-inf"], +) +def test_unusable_entries_fall_back(text): + assert safe_float(text, 42.0) == 42.0 + + +@pytest.mark.parametrize("text", ["0", "0.0", " 0 "]) +def test_zero_falls_back_because_it_would_divide(text): + """A pulse equivalent of zero must never reach the controller.""" + assert safe_float(text, 7.0) == 7.0 + + +@pytest.mark.parametrize(("text", "expected"), [("5", 5.0), (" 2.5 ", 2.5), ("-3", -3.0)]) +def test_usable_entries_are_parsed(text, expected): + assert safe_float(text, 42.0) == expected diff --git a/apps/autocal/tools/layout_mockup.py b/apps/autocal/tools/layout_mockup.py new file mode 100644 index 0000000..616cbe0 --- /dev/null +++ b/apps/autocal/tools/layout_mockup.py @@ -0,0 +1,1010 @@ +#!/usr/bin/env python3 +"""Draw the *proposed* AutoCal console as a picture. + +This is a wireframe, not the application. It exists so the layout can be +reviewed and marked up before (or independently of) the real panels being +built — there is no controller, no sensor and no 3D behind it, and it imports +nothing from ``orisys_autocal`` except the shared theme tokens. + + python apps/autocal/tools/layout_mockup.py --out layout.png + python apps/autocal/tools/layout_mockup.py --theme light --language en_US + +Canvas contents: + +* the **full page** at the design resolution (menu bar, permanent status strip, + three columns, status bar) with the *Axis debug* tab active; +* a **sequence design page**, drawn at the same size, because that interface is + a design question in its own right (workflow P5); +* **previews** of the remaining three tabs, so the whole plan is in one image. + +Numbers are pixels and follow the implementation's measured minimums (setup rail +326, readout rail 220, workspace the remainder). +""" + +from __future__ import annotations + +import argparse +import math +import os +import sys +from pathlib import Path + +APP_ROOT = Path(__file__).resolve().parents[1] +for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +# No display and no bundled fonts: the offscreen platform needs both told. +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) + +from PySide6.QtCore import QPoint, QRect, Qt # noqa: E402 +from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPixmap, QPolygon # noqa: E402 +from PySide6.QtWidgets import QApplication # noqa: E402 + +# ---------------------------------------------------------------- geometry --- + +PAGE_W, PAGE_H = 1920, 1080 +MARGIN = 8 +MENU_H = 24 +BRAND_H = 36 +STRIP_H = 44 +RUNBAR_H = 40 +STATUS_H = 26 +RAIL_LEFT = 330 +WORKSPACE_W = PAGE_W - RAIL_LEFT - 3 * MARGIN + +PREVIEW_W, PREVIEW_H = 640, 340 + +#: Column widths of the axis table: seven data columns plus the actions group. +AXIS_COL_W = (46, 96, 100, 100, 130, 130, 130) +AXIS_ACTIONS_W = 166 + +STRINGS = { + "zh_CN": { + "menu": ["文件", "视图", "帮助"], + "brand_detail": "å� åŠ¨ç§‘æŠ€", + "chip_conn": "已连接", + "chip_bus": "EtherCAT", + "chip_init": "åˆ�始化æˆ�功", + "chip_axes": "已使能 5/5 è½´", + "chip_health": "刷新 12 ms å‰�", + "strip_title": "è¿�动控制", + "pause": "æš‚å�œåˆ·æ–°", + "stop_all": "全部å�œæ­¢", + "run_title": "è¿�行", + "run_phase": "空闲", + "run_step": "第 0/0 点", + "run_time": "已用 0:00 · 预计剩余 —", + "run_run": "è¿�行", + "run_pause": "æš‚å�œ", + "run_abort": "中止", + "conn_title": "连接控制器", + "conn_mode": "连接方å¼�", + "conn_mode_value": "以太网", + "conn_ip": "IP", + "scan": "IP 扫æ��", + "connect": "连接", + "disconnect": "断开连接", + "connected": "已连接", + "bus_title": "总线åˆ�始化", + "bus_file": "BAS 文件", + "choose": "选择 BAS…", + "download": "下载并åˆ�始化", + "reinit": "é‡�æ–°åˆ�始化", + "bus_state": "åˆ�始化状æ€�", + "bus_ok": "åˆ�始化æˆ�功", + "bus_type": "总线类型", + "nodes": "节点数é‡�", + "axes_count": "è½´æ•°é‡�", + "busdata_group": "总线数æ�® (SDO / RTEX) â–¶", + "tabs": ["轴调试", "测试åº�列", "传感器", "表é�¢åˆ†å¸ƒ", "标定点"], + "readout_title": "轴状æ€�", + "enabled": "已使能", + "disabled": "未使能", + "position": "0.000 (0.000 mm)", + "enable_all": "全部关闭使能", + "clear_alarms": "清除全部报警", + "status_bar": "状æ€�刷新正常 | 刷新间隔 100 ms | åˆ�始化æˆ�功", + "axis_label": "è½´å�·", + "axis_params": ["脉冲当é‡�", "速度", "加速度"], + "axis_values": ["10.000", "1000.0", "10000.0"], + "home_fields": ["回零模å¼�", "回零高速", "回零低速", "回零å��ç§»"], + "home_values": ["0", "50.0", "10.0", "0.000"], + "apply": "应用å�‚æ•°", + "home": "å�¯åŠ¨å›žé›¶", + "clear_alarm": "清除报警", + "target": "目标ä½�ç½®", + "move_abs": "ç»�对定ä½�", + "move_rel": "相对è¿�动", + "mm_hint": "毫米值由脉冲当é‡�æ�¢ç®—得出(显示值,é�žæ ‡å®šé•¿åº¦ï¼‰", + "cols": ["è½´å�·", "使能", "脉冲当é‡�", "速度", "命令ä½�ç½®", "ä½�ç½® (mm)", "状æ€�"], + "row_actions": ["正转", "å��转", "å�œæ­¢"], + "actions_header": "æ“�作", + "axis_detail": "选中轴 0: 轴类型 65 (ä½�ç½®) · 加速度 10000.0 · å��馈 999.000 (1.998 mm) · 轴状æ€� 0 · 回零完æˆ�", + "seq_profile": "标定点生æˆ�", + "seq_preview": "预览", + "seq_validation": "校验", + "seq_points": "标定点列表", + "seq_fields": [ + ("曲é�¢æ¨¡å¼�", "å¹³é�¢"), ("点è·� (mm)", "2.000"), ("边界余é‡� (mm)", "1.000"), + ("曲é�¢å�Šå¾„ (mm)", "—"), ("安全高度 (mm)", "5.000"), ("进给速度 (mm/s)", "2.000"), + ("下压速度 (mm/s)", "0.500"), ("下压深度 (mm)", "0.200"), ("ä¿�压 (s)", "0.500"), + ("稳定窗å�£ (s)", "0.500"), ("é‡�å¤�次数", "3"), ("é��历顺åº�", "蛇形"), + ], + "seq_preview_placeholder": "三维预览:传感器表é�¢ · 标定点 · é��历顺åº�", + "seq_preview_note": "(仿真阶段实现)", + "seq_preview_hint": "生æˆ�å�Žå�¯åœ¨æ­¤æ£€æŸ¥ç‚¹æ˜¯å�¦è�½åœ¨æ›²é�¢ä¸Šã€�是å�¦åœ¨è¡Œç¨‹å†…ã€�接触能å�¦è¢«ç¡®è®¤ã€‚", + "seq_checks": ["在曲é�¢èŒƒå›´å†…", "在行程内", "接触å�¯ç¡®è®¤"], + "seq_checks_pending": "尚未生æˆ�标定点。", + "seq_buttons": [("生æˆ�标定点", True), ("仿真", True), ("干跑(ä¸�接触)", True), ("ä¿�å­˜åº�列…", True), ("载入åº�列…", True)], + "point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状æ€�", "ç�µæ•�度(上次)", "样本", "æ“�作"], + "point_rows": [ + ["1", "0.000", "0.000", "12.400", "待测", "—", "—", ""], + ["2", "2.000", "0.000", "12.400", "待测", "—", "—", ""], + ["3", "4.000", "0.000", "12.400", "待测", "—", "—", ""], + ], + "points_hint": "å��标由网格生æˆ�å¹¶é€�点校验,ä¸�接å�—手工输入:手工输入的å��标没有任何检查,一个错数字会æ¯�掉整æ�¡åº�列。", + "sensor_note": "设备与信å�·æº�ã€�实时图åƒ�ã€�力曲线", + "sensor_curve": "力曲线 (FN / FX / FY)", + "surface_note": "多点标定ç�µæ•�度 → 三维表é�¢è‰²å›¾", + "legend": "ç�µæ•�度 (N/å�•ä½�)", + "surface_footer": "12 个标定点 · 几何 finger-1.0.0 · 拖动旋转 · 滚轮缩放", + "points_page_note": "标定点列表:平å�°å��æ ‡ · å�‚考载è�· · 采样统计 · ç�µæ•�度", + "previews_caption": "其余工作区标签页", + }, + "en_US": { + "menu": ["File", "View", "Help"], + "brand_detail": "Orisys Technology", + "chip_conn": "Connected", + "chip_bus": "EtherCAT", + "chip_init": "Initialised", + "chip_axes": "5/5 axes enabled", + "chip_health": "Read 12 ms ago", + "strip_title": "Motion", + "pause": "Pause refresh", + "stop_all": "Stop all", + "run_title": "Run", + "run_phase": "Idle", + "run_step": "Point 0/0", + "run_time": "Elapsed 0:00 · about — left", + "run_run": "Run", + "run_pause": "Pause", + "run_abort": "Abort", + "conn_title": "Controller connection", + "conn_mode": "Connection", + "conn_mode_value": "Ethernet", + "conn_ip": "IP", + "scan": "Scan IP", + "connect": "Connect", + "disconnect": "Disconnect", + "connected": "Connected", + "bus_title": "Bus initialisation", + "bus_file": "BAS file", + "choose": "Choose BAS…", + "download": "Download and initialise", + "reinit": "Re-initialise", + "bus_state": "Init state", + "bus_ok": "Initialised", + "bus_type": "Bus type", + "nodes": "Nodes", + "axes_count": "Axes", + "busdata_group": "Bus data (SDO / RTEX) â–¶", + "tabs": ["Axis debug", "Test sequence", "Sensor", "Surface map", "Calibration points"], + "readout_title": "Axis state", + "enabled": "Enabled", + "disabled": "Disabled", + "position": "0.000 (0.000 mm)", + "enable_all": "Disable all", + "clear_alarms": "Clear all alarms", + "status_bar": "State refresh healthy | Refresh every 100 ms | Initialised", + "axis_label": "Axis", + "axis_params": ["Units", "Speed", "Accel"], + "axis_values": ["10.000", "1000.0", "10000.0"], + "home_fields": ["Home mode", "Home speed", "Home creep", "Home offset"], + "home_values": ["0", "50.0", "10.0", "0.000"], + "apply": "Apply", + "home": "Home", + "clear_alarm": "Clear alarm", + "target": "Target", + "move_abs": "Move abs", + "move_rel": "Move rel", + "mm_hint": "Millimetres are derived from the pulse equivalent (display only)", + "cols": ["Axis", "Enable", "Units", "Speed", "DPOS", "DPOS(mm)", "Status"], + "row_actions": ["Fwd", "Rev", "Stop"], + "actions_header": "Actions", + "axis_detail": "Axis 0: type 65 (position) · accel 10000.0 · feedback 999.000 (1.998 mm) · status 0 · homed", + "seq_profile": "Point generation", + "seq_preview": "Preview", + "seq_validation": "Checks", + "seq_points": "Calibration points", + "seq_fields": [ + ("Surface mode", "Flat"), ("Pitch (mm)", "2.000"), ("Margin (mm)", "1.000"), + ("Radius (mm)", "—"), ("Clear height (mm)", "5.000"), ("Approach (mm/s)", "2.000"), + ("Press speed (mm/s)", "0.500"), ("Press depth (mm)", "0.200"), ("Dwell (s)", "0.500"), + ("Settle window (s)", "0.500"), ("Repeats", "3"), ("Point order", "Serpentine"), + ], + "seq_preview_placeholder": "3D preview: sensor surface · points · visiting order", + "seq_preview_note": "(implemented in the simulation phase)", + "seq_preview_hint": "After generation, check each point is on the surface, inside travel and confirmable by contact.", + "seq_checks": ["On the surface", "Within travel", "Contact confirmable"], + "seq_checks_pending": "No points generated yet.", + "seq_buttons": [("Generate points", True), ("Simulate", True), ("Dry run (no contact)", True), ("Save sequence…", True), ("Load sequence…", True)], + "point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples", "Actions"], + "point_rows": [ + ["1", "0.000", "0.000", "12.400", "Pending", "—", "—", ""], + ["2", "2.000", "0.000", "12.400", "Pending", "—", "—", ""], + ["3", "4.000", "0.000", "12.400", "Pending", "—", "—", ""], + ], + "points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked.", + "sensor_note": "Device and source, live image, force curves", + "sensor_curve": "Force curves (FN / FX / FY)", + "surface_note": "Multi-point calibration sensitivity → 3D surface colour map", + "legend": "Sensitivity (N/unit)", + "surface_footer": "12 points · geometry finger-1.0.0 · drag to rotate · wheel to zoom", + "points_page_note": "Calibration points: platform coords · reference load · statistics · sensitivity", + "previews_caption": "Remaining workspace tabs", + }, +} + + +class Palette: + """Colours from the shared theme, so the mockup matches the product.""" + + def __init__(self, theme: str) -> None: + from orisys_app_shared.theme import load_colors + + self.raw = load_colors(theme) + self.window = self._c("window_bg", "#f4f5f7") + self.panel = self._c("panel_bg", "#ffffff") + self.border = self._c("panel_border", "#d0d5dd") + self.text = self._c("text_primary", "#101828") + self.muted = self._c("text_muted", "#667085") + self.accent = self._c("accent", "#2f6fed") + self.warn = self._c("warn", "#b7791f") + self.danger = self._c("danger", "#d92d20") + self.bar = self._c("brand_bar_bg", "#ffffff") + + def _c(self, key: str, fallback: str) -> QColor: + return QColor(self.raw.get(key) or fallback) + + +# ------------------------------------------------------------------ helpers --- + + +def _font(size: int = 11, *, bold: bool = False) -> QFont: + font = QFont() + font.setPointSize(size) + font.setBold(bold) + return font + + +def _panel(painter: QPainter, rect: QRect, palette: Palette) -> QRect: + painter.fillRect(rect, palette.panel) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(rect.adjusted(0, 0, -1, -1)) + return rect.adjusted(10, 8, -10, -10) + + +def _box_title(painter: QPainter, rect: QRect, text: str, palette: Palette) -> None: + """A group-box title, drawn on the frame like Qt does.""" + painter.setFont(_font(10, bold=True)) + painter.setPen(QPen(palette.text)) + metrics = painter.fontMetrics() + width = metrics.horizontalAdvance(text) + 12 + painter.fillRect(QRect(rect.x() + 10, rect.y() - metrics.height() // 2, width, metrics.height()), palette.panel) + painter.drawText( + QRect(rect.x() + 14, rect.y() - metrics.height() // 2, width, metrics.height()), + Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, + text, + ) + + +def _text( + painter: QPainter, + point_or_rect: QPoint | QRect, + text: str, + palette: Palette, + *, + size: int = 11, + bold: bool = False, + muted: bool = False, + colour: QColor | None = None, + align=Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, +) -> None: + painter.setFont(_font(size, bold=bold)) + painter.setPen(QPen(colour or (palette.muted if muted else palette.text))) + if isinstance(point_or_rect, QRect): + painter.drawText(point_or_rect, align | Qt.TextFlag.TextWordWrap if text.count("\n") else align, text) + return + painter.drawText(point_or_rect, text) + + +def _chip(painter: QPainter, pos: QPoint, text: str, palette: Palette, colour: QColor) -> int: + painter.setFont(_font(10)) + width = painter.fontMetrics().horizontalAdvance(text) + 20 + rect = QRect(pos.x(), pos.y(), width, 22) + painter.setPen(QPen(colour, 1)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(rect, 10, 10) + painter.setPen(QPen(colour)) + painter.setFont(_font(10)) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text) + return width + 8 + + +def _button( + painter: QPainter, + rect: QRect, + text: str, + palette: Palette, + *, + primary: bool = False, + danger: bool = False, + disabled: bool = False, +) -> None: + if disabled: + painter.fillRect(rect, palette.window) + label = palette.muted + elif primary: + painter.fillRect(rect, palette.accent) + label = QColor("#ffffff") + elif danger: + painter.fillRect(rect, palette.danger) + label = QColor("#ffffff") + else: + painter.fillRect(rect, palette.panel) + label = palette.text + painter.setPen(QPen(palette.border if not (primary or danger) else (palette.accent if primary else palette.danger), 1)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRect(rect.adjusted(0, 0, -1, -1)) + painter.setPen(QPen(label)) + painter.setFont(_font(10)) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text) + + +def _field(painter: QPainter, rect: QRect, text: str, palette: Palette, *, disabled: bool = False) -> None: + painter.fillRect(rect, palette.panel if not disabled else palette.window) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(rect.adjusted(0, 0, -1, -1)) + painter.setFont(_font(10)) + painter.setPen(QPen(palette.muted if disabled else palette.text)) + painter.drawText( + rect.adjusted(6, 0, -6, 0), Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, text + ) + + +# --------------------------------------------------------------- the pieces --- + + +def draw_chrome(painter: QPainter, palette: Palette, s: dict) -> None: + painter.fillRect(QRect(0, 0, PAGE_W, MENU_H), palette.bar) + x = 12 + for item in s["menu"]: + _text(painter, QPoint(x, MENU_H - 8), item, palette, size=10) + x += painter.fontMetrics().horizontalAdvance(item) + 24 + + painter.fillRect(QRect(0, MENU_H, PAGE_W, BRAND_H), palette.bar) + painter.setPen(QPen(palette.border, 1)) + painter.drawLine(0, MENU_H + BRAND_H - 1, PAGE_W, MENU_H + BRAND_H - 1) + painter.setPen(QPen(palette.accent, 2)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRect(QRect(14, MENU_H + 9, 18, 18)) + _text(painter, QPoint(42, MENU_H + BRAND_H - 13), "ORISYS", palette, size=13, bold=True) + _text(painter, QPoint(112, MENU_H + BRAND_H - 13), s["brand_detail"], palette, size=10, muted=True) + _text( + painter, + QRect(PAGE_W - 700, MENU_H, 560, BRAND_H), + "Orisys AutoCal — 标定å�°æŽ§åˆ¶ / Platform Control", + palette, + size=11, + colour=palette.text, + align=Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + ) + _chip(painter, QPoint(PAGE_W - 128, MENU_H + 8), s["chip_conn"], palette, palette.accent) + + +def draw_status_strip(painter: QPainter, palette: Palette, s: dict, top: int) -> None: + painter.fillRect(QRect(0, top, PAGE_W, STRIP_H), palette.panel) + painter.setPen(QPen(palette.border, 1)) + painter.drawLine(0, top, PAGE_W, top) + painter.drawLine(0, top + STRIP_H - 1, PAGE_W, top + STRIP_H - 1) + + x = 16 + _text(painter, QPoint(x, top + 28), s["strip_title"], palette, size=11, bold=True) + x += painter.fontMetrics().horizontalAdvance(s["strip_title"]) + 20 + for label in (s["chip_conn"], s["chip_bus"], s["chip_init"], s["chip_axes"], s["chip_health"]): + x += _chip(painter, QPoint(x, top + 11), label, palette, palette.accent) + + _text( + painter, + QRect(PAGE_W - 600, top, 380, STRIP_H), + "上次动作: 已使能轴0 / last action: enable axis 0", + palette, + size=10, + muted=True, + align=Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + ) + _button(painter, QRect(PAGE_W - 210, top + 10, 96, 24), s["pause"], palette) + _button(painter, QRect(PAGE_W - 106, top + 10, 92, 24), s["stop_all"], palette, danger=True) + + +def draw_status_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> None: + """The footer, using its own token rather than the header's navy.""" + background = QColor(palette.raw.get("status_bar_bg") or "#EDF1F8") + foreground = QColor(palette.raw.get("status_bar_text") or "#141A24") + painter.fillRect(QRect(0, top, PAGE_W, STATUS_H), background) + painter.setPen(QPen(palette.border, 1)) + painter.drawLine(0, top, PAGE_W, top) + _text(painter, QPoint(12, top + 17), s["status_bar"], palette, size=10, colour=foreground) + + +def draw_left_rail(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None: + conn = QRect(rect.x(), rect.y(), rect.width(), 210) + inner = _panel(painter, conn, palette) + _box_title(painter, conn, s["conn_title"], palette) + y = inner.y() + 6 + _text(painter, QRect(inner.x(), y, 76, 22), s["conn_mode"], palette, size=10, muted=True) + _field(painter, QRect(inner.x() + 78, y, inner.width() - 78, 22), s["conn_mode_value"] + " â–¾", palette) + y += 30 + _text(painter, QRect(inner.x(), y, 30, 22), s["conn_ip"], palette, size=10, muted=True) + _field(painter, QRect(inner.x() + 32, y, 140, 22), "192.168.0.11", palette) + _button(painter, QRect(inner.x() + 180, y, 88, 22), s["scan"], palette) + y += 32 + _button(painter, QRect(inner.x(), y, 104, 26), s["connect"], palette, primary=True) + _button(painter, QRect(inner.x() + 112, y, 108, 26), s["disconnect"], palette) + y += 36 + _text(painter, QRect(inner.x(), y, inner.width(), 20), s["connected"], palette, size=10, muted=True) + y += 24 + _text(painter, QRect(inner.x(), y, inner.width(), 20), "demo (simulated platform)", palette, size=9, muted=True) + + bus = QRect(rect.x(), rect.y() + 222, rect.width(), 250) + inner = _panel(painter, bus, palette) + _box_title(painter, bus, s["bus_title"], palette) + y = inner.y() + 6 + _text(painter, QRect(inner.x(), y, 60, 22), s["bus_file"], palette, size=10, muted=True) + _field(painter, QRect(inner.x() + 62, y, 140, 22), "…\\bas\\ECAT.bas", palette) + _button(painter, QRect(inner.x() + 208, y, 66, 22), s["choose"], palette) + y += 30 + _text(painter, QRect(inner.x(), y, 74, 20), s["bus_state"], palette, size=10, muted=True) + _text(painter, QRect(inner.x() + 76, y, 180, 20), s["bus_ok"], palette, size=10) + y += 24 + _text(painter, QRect(inner.x(), y, 74, 20), s["bus_type"], palette, size=10, muted=True) + _text(painter, QRect(inner.x() + 76, y, 180, 20), s["chip_bus"], palette, size=10) + y += 24 + _text(painter, QRect(inner.x(), y, 90, 20), s["nodes"], palette, size=10, muted=True) + _text(painter, QRect(inner.x() + 92, y, 40, 20), "5", palette, size=10) + _text(painter, QRect(inner.x() + 146, y, 70, 20), s["axes_count"], palette, size=10, muted=True) + _text(painter, QRect(inner.x() + 218, y, 40, 20), "5", palette, size=10) + y += 32 + _button(painter, QRect(inner.x(), y, 146, 26), s["download"], palette, primary=True) + _button(painter, QRect(inner.x() + 154, y, 104, 26), s["reinit"], palette) + + group = QRect(rect.x(), rect.y() + 484, rect.width(), 32) + _panel(painter, group.adjusted(0, 0, 0, -8), palette) + _text(painter, QRect(group.x() + 12, group.y(), group.width() - 24, 30), s["busdata_group"], palette, size=10, muted=True) + + # The axis readout lives in this rail now, not in a second one on the right: + # two thin rails cost the workspace 240 px at every window size. + readout = QRect(rect.x(), rect.y() + 526, rect.width(), 268) + inner = _panel(painter, readout, palette) + _box_title(painter, readout, s["readout_title"], palette) + y = inner.y() + 6 + for axis in range(5): + _text(painter, QRect(inner.x(), y, 16, 22), str(axis), palette, size=10, muted=True) + _text(painter, QRect(inner.x() + 20, y, 62, 22), s["enabled"], palette, size=9, muted=True) + _text( + painter, + QRect(inner.x() + 84, y, inner.width() - 88, 22), + s["position"], + palette, + size=10, + align=Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + ) + y += 24 + _button(painter, QRect(inner.x(), y + 4, inner.width(), 28), s["enable_all"], palette) + _button(painter, QRect(inner.x(), y + 40, inner.width(), 28), s["stop_all"], palette, danger=True) + _button(painter, QRect(inner.x(), y + 76, inner.width(), 28), s["clear_alarms"], palette) + + +def draw_tabs(painter: QPainter, palette: Palette, s: dict, workspace: QRect, active: str) -> QRect: + tab_h = 30 + tab_w = workspace.width() // len(s["tabs"]) + for index, name in enumerate(s["tabs"]): + tab = QRect(workspace.x() + index * tab_w, workspace.y(), tab_w, tab_h) + is_active = name == active + painter.fillRect(tab, palette.panel if not is_active else palette.window) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(tab.adjusted(0, 0, -1, -1)) + if is_active: + painter.setPen(QPen(palette.accent, 2)) + painter.drawLine(tab.bottomLeft(), tab.bottomRight()) + _text( + painter, + tab, + name, + palette, + size=10, + bold=is_active, + colour=palette.text if is_active else palette.muted, + align=Qt.AlignmentFlag.AlignCenter, + ) + return QRect( + workspace.x() + 1, + workspace.y() + tab_h + 1, + workspace.width() - 2, + workspace.height() - tab_h - 2, + ) + + +def draw_axis_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None: + inner = rect.adjusted(10, 8, -10, -8) + y = inner.y() + + _text(painter, QRect(inner.x(), y, 44, 22), s["axis_label"], palette, size=10, muted=True) + _text(painter, QRect(inner.x() + 46, y, 30, 22), "0", palette, size=10) + x = inner.x() + 96 + for label, value in zip(s["axis_params"], s["axis_values"]): + _text(painter, QRect(x, y, 90, 22), label, palette, size=10, muted=True) + x += painter.fontMetrics().horizontalAdvance(label) + 8 + _field(painter, QRect(x, y, 92, 22), value, palette) + x += 108 + y += 32 + x = inner.x() + for label, value in zip(s["home_fields"], s["home_values"]): + _text(painter, QRect(x, y, 80, 22), label, palette, size=10, muted=True) + x += painter.fontMetrics().horizontalAdvance(label) + 8 + _field(painter, QRect(x, y, 66, 22), value, palette) + x += 82 + y += 34 + + _button(painter, QRect(inner.x(), y, 116, 26), s["apply"], palette, primary=True) + _button(painter, QRect(inner.x() + 126, y, 106, 26), s["home"], palette) + _button(painter, QRect(inner.x() + 242, y, 106, 26), s["clear_alarm"], palette) + _text( + painter, + QRect(inner.x() + 380, y, inner.width() - 384, 26), + s["mm_hint"], + palette, + size=9, + muted=True, + align=Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + ) + y += 36 + + _text(painter, QRect(inner.x(), y, 64, 22), s["target"], palette, size=10, muted=True) + _field(painter, QRect(inner.x() + 66, y, 120, 22), "1000", palette) + _button(painter, QRect(inner.x() + 196, y, 96, 24), s["move_abs"], palette) + _button(painter, QRect(inner.x() + 300, y, 96, 24), s["move_rel"], palette) + y += 34 + + table_h = rect.height() - (y - rect.y()) - 96 + table = QRect(inner.x(), y, inner.width(), table_h) + painter.fillRect(table, palette.window) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(table.adjusted(0, 0, -1, -1)) + + actions_w = AXIS_ACTIONS_W + data_w = sum(AXIS_COL_W) + header_h = 26 + painter.fillRect(QRect(table.x(), table.y(), data_w, header_h), palette.panel) + x = table.x() + for name, width in zip(s["cols"], AXIS_COL_W): + _text(painter, QRect(x, table.y(), width, header_h), name, palette, size=9, muted=True, align=Qt.AlignmentFlag.AlignCenter) + x += width + _text( + painter, + QRect(table.x() + data_w, table.y(), actions_w, header_h), + s["actions_header"], + palette, + size=9, + muted=True, + align=Qt.AlignmentFlag.AlignCenter, + ) + + row_h = 40 + values = ["0", "已使能", "10.000", "1000.0", "1000.000", "2.000", "å�œæ­¢"] + en_values = ["0", "Enabled", "10.000", "1000.0", "1000.000", "2.000", "Stopped"] + row_values = values if s["axis_label"] == "è½´å�·" else en_values + for row in range(5): + row_y = table.y() + header_h + row * row_h + if row_y + row_h > table.bottom(): + break + if row % 2 == 0: + painter.fillRect(QRect(table.x() + 1, row_y, data_w + actions_w - 2, row_h - 1), palette.panel) + x = table.x() + for column, (name, width) in enumerate(zip(s["cols"], AXIS_COL_W)): + value = str(row) if column == 0 else row_values[column] + _text(painter, QRect(x, row_y, width, row_h), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter if column else Qt.AlignmentFlag.AlignLeft) + x += width + offset = table.x() + data_w + 8 + for label in s["row_actions"]: + _button(painter, QRect(offset, row_y + 8, 46, 24), label, palette) + offset += 50 + + y = table.bottom() + 8 + _text(painter, QRect(inner.x(), y, inner.width(), 20), s["axis_detail"], palette, size=9, muted=True) + y += 26 + _button(painter, QRect(inner.x(), y, 116, 28), s["stop_all"], palette, danger=True) + + +def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None: + inner = rect.adjusted(10, 8, -10, -8) + profile_w, validation_w = 420, 280 + gap = 12 + preview_w = inner.width() - profile_w - validation_w - 2 * gap + # The zones get what they need; the point list takes the rest, because it is + # the part that grows. + top_h = 260 + + # --- profile: two columns of six fields, so the whole set is visible at once + profile = QRect(inner.x(), inner.y(), profile_w, top_h) + p_inner = _panel(painter, profile, palette) + _box_title(painter, profile, s["seq_profile"], palette) + column_w = p_inner.width() // 2 + for index, (label, value) in enumerate(s["seq_fields"]): + row, side = divmod(index, 2) + x = p_inner.x() + side * column_w + y = p_inner.y() + 20 + row * 26 + _text(painter, QRect(x, y, 130, 22), label, palette, size=9, muted=True) + _field(painter, QRect(x + 128, y, column_w - 140, 22), value, palette) + + # --- preview + preview = QRect(inner.x() + profile_w + gap, inner.y(), preview_w, top_h) + q_inner = _panel(painter, preview, palette) + _box_title(painter, preview, s["seq_preview"], palette) + view = QRect(q_inner.x(), q_inner.y() + 4, q_inner.width(), q_inner.height() - 42) + painter.fillRect(view, palette.window) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(view.adjusted(0, 0, -1, -1)) + # a stand-in surface with grid dots and the visiting path + cx, cy = view.center().x(), view.center().y() + 6 + rx, ry = view.width() // 4, view.height() // 3 + for row in range(6): + for column in range(6): + u = (column - 2.5) / 2.5 + v = (row - 2.5) / 2.5 + if u * u + v * v > 1.25: + continue + px = int(cx + u * rx) + py = int(cy + v * ry) + painter.setPen(QPen(palette.accent, 1)) + painter.setBrush(palette.accent) + painter.drawEllipse(QPoint(px, py), 3, 3) + painter.setPen(QPen(palette.warn, 1, Qt.PenStyle.DashLine)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawEllipse(QPoint(cx, cy), rx, ry) + _text(painter, QRect(view.x() + 10, view.y() + 8, view.width(), 20), s["seq_preview_placeholder"], palette, size=10, bold=True) + _text(painter, QRect(view.x() + 10, view.y() + 28, view.width(), 20), s["seq_preview_note"], palette, size=9, muted=True) + _text(painter, QRect(q_inner.x(), view.bottom() + 4, q_inner.width(), 34), s["seq_preview_hint"], palette, size=9, muted=True) + + # --- validation + validation = QRect(inner.x() + profile_w + preview_w + 2 * gap, inner.y(), validation_w, top_h) + v_inner = _panel(painter, validation, palette) + _box_title(painter, validation, s["seq_validation"], palette) + y = v_inner.y() + 6 + for label in s["seq_checks"]: + _text(painter, QRect(v_inner.x(), y, v_inner.width(), 22), "â—‹ " + label + ": —", palette, size=10, muted=True) + y += 24 + _text(painter, QRect(v_inner.x(), y + 6, v_inner.width(), 40), s["seq_checks_pending"], palette, size=9, muted=True) + y = validation.bottom() - len(s["seq_buttons"]) * 31 - 6 + for label, _disabled in s["seq_buttons"]: + _button(painter, QRect(v_inner.x(), y, v_inner.width(), 26), label, palette, disabled=True) + y += 31 + + # --- point list, full width + points = QRect(inner.x(), inner.y() + top_h + gap, inner.width(), inner.height() - top_h - gap) + t_inner = _panel(painter, points, palette) + _box_title(painter, points, s["seq_points"], palette) + widths = (50, 150, 150, 150, 130, 190, 100) + widths = widths + (t_inner.width() - sum(widths),) + header_h = 28 + header = QRect(t_inner.x(), t_inner.y() + 4, t_inner.width(), header_h) + painter.fillRect(header, palette.window) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(header.adjusted(0, 0, -1, -1)) + x = t_inner.x() + for name, width in zip(s["point_cols"], widths): + _text(painter, QRect(x, header.y(), width, header_h), name, palette, size=9, muted=True, align=Qt.AlignmentFlag.AlignCenter) + x += width + row_h = 32 + for index, row in enumerate(s["point_rows"]): + row_y = header.bottom() + 1 + index * row_h + if index % 2 == 1: + painter.fillRect(QRect(t_inner.x(), row_y, t_inner.width(), row_h - 1), palette.window) + x = t_inner.x() + for value, width in zip(row, widths): + _text(painter, QRect(x, row_y, width, row_h), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter) + x += width + y = header.bottom() + 1 + len(s["point_rows"]) * row_h + 10 + _text(painter, QRect(t_inner.x(), y, t_inner.width(), 34), s["points_hint"], palette, size=9, muted=True) + + +def draw_sensor_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None: + inner = rect.adjusted(10, 8, -10, -8) + y = inner.y() + _text(painter, QRect(inner.x(), y, 60, 22), "设备" if s["axis_label"] == "è½´å�·" else "Device", palette, size=10, muted=True) + _field(painter, QRect(inner.x() + 62, y, 180, 22), "finger â–¾", palette) + _button(painter, QRect(inner.x() + 252, y, 70, 22), "预览" if s["axis_label"] == "è½´å�·" else "Preview", palette) + y += 30 + _text(painter, QRect(inner.x(), y, 400, 20), s["sensor_note"], palette, size=9, muted=True) + y += 26 + + image = QRect(inner.x(), y, int(inner.width() * 0.34), inner.height() - (y - rect.y()) - 10) + painter.fillRect(image, palette.window) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(image.adjusted(0, 0, -1, -1)) + for i in range(1, 4): + painter.drawLine(image.x() + i * image.width() // 4, image.y() + 6, image.x() + i * image.width() // 4, image.bottom() - 6) + + curve = QRect(image.right() + 12, y - 56, inner.right() - image.right() - 12, image.height() + 56) + painter.fillRect(curve, palette.window) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(curve.adjusted(0, 0, -1, -1)) + _text(painter, QRect(curve.x() + 10, curve.y() + 8, curve.width(), 20), s["sensor_curve"], palette, size=10, bold=True) + plot = curve.adjusted(14, 40, -14, -28) + painter.setPen(QPen(palette.border, 1)) + painter.drawLine(plot.bottomLeft(), plot.bottomRight()) + painter.drawLine(plot.bottomLeft(), plot.topLeft()) + for index, colour in enumerate((palette.accent, palette.warn, palette.danger)): + painter.setPen(QPen(colour, 2)) + points = [] + for step in range(80): + t = step / 79 + value = (1 - t) ** 2 * (0.2 + 0.22 * index) + 0.16 * (1 + index) * (1 - abs(0.5 - t) * 2) + points.append((plot.x() + int(t * plot.width()), plot.bottom() - int(min(1.0, value) * plot.height()))) + for first, second in zip(points, points[1:]): + painter.drawLine(first[0], first[1], second[0], second[1]) + + +def draw_surface_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None: + inner = rect.adjusted(10, 8, -10, -8) + view = QRect(inner.x(), inner.y(), inner.width() - 220, inner.height() - 30) + painter.fillRect(view, palette.window) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(view.adjusted(0, 0, -1, -1)) + + cx, cy = view.center().x(), view.center().y() + 10 + rx, ry = view.width() // 3, view.height() // 3 + steps = 26 + grid = [] + for row in range(steps + 1): + v = row / steps + line = [] + for column in range(steps + 1): + u = column / steps + angle = 2 * math.pi * u + line.append((cx + int(rx * math.cos(angle) * math.sin(math.pi * v)), cy - int(ry * math.cos(math.pi * v)))) + grid.append(line) + for row in range(steps): + for column in range(steps): + intensity = max(0.0, min(1.0, 0.5 + 0.45 * (column / steps - 0.5) + 0.35 * (row / steps - 0.5))) + colour = QColor(int(40 + 200 * intensity), int(110 + 60 * (1 - abs(intensity - 0.5) * 2)), int(235 - 195 * intensity)) + painter.setPen(QPen(colour, 1)) + painter.setBrush(colour) + painter.drawPolygon( + QPolygon( + [ + QPoint(*grid[row][column]), + QPoint(*grid[row + 1][column]), + QPoint(*grid[row + 1][column + 1]), + QPoint(*grid[row][column + 1]), + ] + ) + ) + _text(painter, QRect(view.x() + 12, view.y() + 8, view.width(), 22), s["surface_note"], palette, size=10, bold=True) + + legend = QRect(view.right() + 12, inner.y(), 200, view.height()) + _text(painter, QRect(legend.x(), legend.y(), legend.width(), 20), s["legend"], palette, size=10, bold=True) + # A legend, not a wall of colour: fixed height so it reads as a scale. + bar_h = min(300, legend.height() - 120) + bar = QRect(legend.x() + 12, legend.y() + 34, 30, bar_h) + for step in range(bar.height()): + t = step / max(1, bar.height() - 1) + colour = QColor(int(40 + 200 * t), int(110 + 60 * (1 - abs(t - 0.5) * 2)), int(235 - 195 * t)) + painter.setPen(QPen(colour, 1)) + painter.drawLine(bar.x(), bar.y() + step, bar.x() + bar.width(), bar.y() + step) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(bar) + for label, offset in (("0.21", 6), ("0.60", bar.height() // 2), ("0.95", bar.height() - 6)): + _text(painter, QPoint(bar.right() + 10, bar.y() + offset), label, palette, size=9) + _text( + painter, + QRect(legend.x(), bar.bottom() + 12, legend.width(), 48), + s["surface_footer"], + palette, + size=9, + muted=True, + ) + + +def draw_points_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None: + inner = rect.adjusted(10, 8, -10, -8) + _text(painter, QRect(inner.x(), inner.y(), inner.width(), 20), s["points_page_note"], palette, size=10, muted=True) + y = inner.y() + 28 + table = QRect(inner.x(), y, inner.width(), inner.height() - 28 - 46) + painter.fillRect(table, palette.window) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(table.adjusted(0, 0, -1, -1)) + widths = (50, 140, 140, 140, 150, 190, 110, 150) + header_h = 26 + painter.fillRect(QRect(table.x(), table.y(), sum(widths), header_h), palette.panel) + x = table.x() + headers = s["point_cols"] + for name, width in zip(headers, widths): + _text(painter, QRect(x, table.y(), width, header_h), name, palette, size=9, muted=True, align=Qt.AlignmentFlag.AlignCenter) + x += width + rows = [ + ["1", "0.00", "0.00", "0.00", "2.0", "0.94", "40", "ok"], + ["2", "2.00", "0.00", "0.00", "2.0", "0.88", "40", "ok"], + ["3", "4.00", "0.00", "0.00", "2.0", "0.81", "40", "å��软" if s["axis_label"] == "è½´å�·" else "soft"], + ] + for index, row in enumerate(rows): + row_y = table.y() + header_h + index * 32 + if index % 2 == 0: + painter.fillRect(QRect(table.x() + 1, row_y, sum(widths) - 2, 31), palette.panel) + x = table.x() + for value, width in zip(row, widths): + _text(painter, QRect(x, row_y, width, 32), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter) + x += width + y = table.bottom() + 10 + _button(painter, QRect(inner.x(), y, 130, 28), "采集当å‰�点" if s["axis_label"] == "è½´å�·" else "Capture point", palette, primary=True) + _button(painter, QRect(inner.x() + 140, y, 100, 28), "é‡�测" if s["axis_label"] == "è½´å�·" else "Re-measure", palette) + _button(painter, QRect(inner.x() + 250, y, 100, 28), "删除" if s["axis_label"] == "è½´å�·" else "Remove", palette) + + +TAB_PAINTERS = { + "axes": draw_axis_tab, + "sequence": draw_sequence_tab, + "sensor": draw_sensor_tab, + "surface": draw_surface_tab, + "points": draw_points_tab, +} + +#: Tab index -> painter key, for the tab strip labels. +TAB_KEYS = ("axes", "sequence", "sensor", "surface", "points") + + +def draw_page( + painter: QPainter, + palette: Palette, + s: dict, + rect: QRect, + *, + active: str = "axes", + with_chrome: bool = True, +) -> None: + """The whole page: chrome, strip, run bar, rail, workspace, status bar.""" + painter.fillRect(rect, palette.window) + body_top = rect.y() + MENU_H + BRAND_H + STRIP_H + RUNBAR_H + body_h = rect.height() - (MENU_H + BRAND_H + STRIP_H + RUNBAR_H) - STATUS_H + + draw_left_rail( + painter, palette, s, QRect(rect.x() + MARGIN, body_top + MARGIN, RAIL_LEFT, body_h - 2 * MARGIN) + ) + + workspace = QRect( + rect.x() + 2 * MARGIN + RAIL_LEFT, + body_top + MARGIN, + rect.width() - RAIL_LEFT - 3 * MARGIN, + body_h - 2 * MARGIN, + ) + painter.fillRect(workspace, palette.panel) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(workspace.adjusted(0, 0, -1, -1)) + content = draw_tabs(painter, palette, s, workspace, s["tabs"][TAB_KEYS.index(active)]) + TAB_PAINTERS[active](painter, palette, s, content) + + if with_chrome: + draw_chrome(painter, palette, s) + draw_status_strip(painter, palette, s, rect.y() + MENU_H + BRAND_H) + draw_run_bar(painter, palette, s, rect.y() + MENU_H + BRAND_H + STRIP_H) + draw_status_bar(painter, palette, s, rect.bottom() - STATUS_H) + + +def draw_run_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> None: + """The run strip: phase, progress and the three controls.""" + painter.fillRect(QRect(0, top, PAGE_W, RUNBAR_H), palette.window) + painter.setPen(QPen(palette.border, 1)) + painter.drawLine(0, top, PAGE_W, top) + painter.drawLine(0, top + RUNBAR_H - 1, PAGE_W, top + RUNBAR_H - 1) + + x = 16 + _text(painter, QPoint(x, top + 25), s["run_title"], palette, size=11, bold=True) + x += painter.fontMetrics().horizontalAdvance(s["run_title"]) + 20 + _text(painter, QRect(x, top, 90, RUNBAR_H), s["run_phase"], palette, size=10, muted=True) + x += 96 + _text(painter, QRect(x, top, 150, RUNBAR_H), s["run_step"], palette, size=10) + x += 158 + + bar = QRect(x, top + 14, 300, 12) + painter.fillRect(bar, palette.border) + painter.fillRect(QRect(bar.x(), bar.y(), 3 * bar.width() // 4, bar.height()), palette.accent) + _text(painter, QRect(bar.right() + 12, top, 260, RUNBAR_H), s["run_time"], palette, size=10, muted=True) + + _button(painter, QRect(PAGE_W - 330, top + 7, 96, 26), s["run_run"], palette, disabled=True) + _button(painter, QRect(PAGE_W - 228, top + 7, 96, 26), s["run_pause"], palette, disabled=True) + _button(painter, QRect(PAGE_W - 126, top + 7, 110, 26), s["run_abort"], palette, danger=True, disabled=True) + + +def draw_previews(painter: QPainter, palette: Palette, s: dict, origin: QPoint, keys: tuple[str, ...]) -> None: + _text(painter, QRect(origin.x(), origin.y(), 900, 24), s["previews_caption"], palette, size=12, bold=True) + row_y = origin.y() + 34 + for index, key in enumerate(keys): + box = QRect(origin.x() + index * (PREVIEW_W + 16), row_y, PREVIEW_W, PREVIEW_H) + painter.fillRect(box, palette.panel) + painter.setPen(QPen(palette.border, 1)) + painter.drawRect(box.adjusted(0, 0, -1, -1)) + header = QRect(box.x(), box.y(), box.width(), 26) + painter.fillRect(header, palette.bar) + painter.setPen(QPen(palette.border, 1)) + painter.drawLine(header.bottomLeft(), header.bottomRight()) + _text( + painter, + QRect(header.x() + 10, header.y(), header.width() - 20, 26), + s["tabs"][TAB_KEYS.index(key)], + palette, + size=10, + bold=True, + ) + content = QRect(box.x() + 6, box.y() + 30, box.width() - 12, box.height() - 36) + painter.save() + painter.setClipRect(content) + scale = content.width() / WORKSPACE_W + painter.translate(content.x(), content.y()) + painter.scale(scale, scale) + virtual = QRect(0, 0, int(content.width() / scale), int(content.height() / scale)) + TAB_PAINTERS[key](painter, palette, s, virtual) + painter.restore() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--theme", default="light", choices=("dark", "light")) + parser.add_argument("--language", default="zh_CN", choices=("zh_CN", "en_US")) + parser.add_argument("--out", default="layout.png") + parser.add_argument("--width", type=int, default=PAGE_W) + parser.add_argument("--height", type=int, default=PAGE_H) + parser.add_argument( + "--page", + default="axes", + choices=("axes", "sequence", "sensor", "surface", "points", "all"), + help="which page to draw; 'all' writes one full-size image per page", + ) + args = parser.parse_args() + + app = QApplication.instance() or QApplication([]) + palette = Palette(args.theme) + s = STRINGS[args.language] + base = Path(args.out).resolve() + base.parent.mkdir(parents=True, exist_ok=True) + + pages = TAB_KEYS if args.page == "all" else (args.page,) + failures = 0 + for key in pages: + target = base if args.page != "all" else base.with_name(f"{base.stem}_{key}{base.suffix or '.png'}") + pixmap = QPixmap(args.width, args.height) + pixmap.fill(palette.window) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + try: + draw_page(painter, palette, s, QRect(0, 0, args.width, args.height), active=key) + finally: + painter.end() + if pixmap.save(str(target)): + print( + f"wrote {target} ({args.width}x{args.height}, theme={args.theme}, " + f"lang={args.language}, page={key})" + ) + else: + print(f"could not write {target}", file=sys.stderr) + failures += 1 + del app + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/autocal/tools/panel_check.py b/apps/autocal/tools/panel_check.py new file mode 100644 index 0000000..d877ef9 --- /dev/null +++ b/apps/autocal/tools/panel_check.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +"""Dependency-free offscreen check of the AutoCal window. + +Builds the real window with the simulated platform and asserts the invariants +that are easy to break and hard to notice: theme tokens present, layout fitting +inside parents, controls gated by connection and bus state, locales complete. + + python apps/autocal/tools/panel_check.py + +No controller, no DLL and no display are needed. Mirrors +``apps/studio/tools/ui_check.py``, but cannot import it: that module imports +PyQt5, and AutoCal's environment has none. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +APP_ROOT = Path(__file__).resolve().parents[1] +for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +os.environ.setdefault("ORISYS_APP_ID", "autocal") +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) + +RESOLUTIONS = ((1440, 900), (1920, 1080)) + + +class Checker: + """Collects failures instead of asserting, so one run reports everything.""" + + def __init__(self) -> None: + self.failures: list[str] = [] + self.checks = 0 + + def that(self, condition: bool, message: str) -> None: + self.checks += 1 + if not condition: + self.failures.append(message) + + def report(self) -> int: + print() + if self.failures: + print(f"FAILED — {len(self.failures)} of {self.checks} checks:") + for failure in self.failures: + print(f" - {failure}") + return 1 + print(f"OK — {self.checks} checks passed") + return 0 + + +def _visible_descendants(widget): + for child in widget.findChildren(object): + if not hasattr(child, "isVisible") or not hasattr(child, "geometry"): + continue + if child.isVisible(): + yield child + + +def main() -> int: + from PySide6 import __version__ as pyside_version + from PySide6.QtWidgets import QApplication + + from orisys_app_shared.i18n import set_locale, t + from orisys_app_shared.theme import build_qss, load_colors + + from orisys_autocal.motion import ConnectionSpec + from orisys_autocal.ui.axis_panel import COLUMNS + from orisys_autocal.ui.bus_panel import default_bas_dir + from orisys_autocal.ui.window import AutoCalMainWindow + + checker = Checker() + app = QApplication.instance() or QApplication([]) + print(f"Qt binding: PySide6 {pyside_version}") + + # ---- theme tokens ----------------------------------------------------- + for theme in ("dark", "light"): + colors = load_colors(theme) + qss = build_qss(theme) + checker.that(bool(colors), f"{theme}: no colour tokens") + checker.that(len(qss) > 500, f"{theme}: the stylesheet looks empty") + checker.that( + len(build_qss(theme)) == len(qss), f"{theme}: the stylesheet is not stable" + ) + + # ---- locale completeness --------------------------------------------- + from orisys_autocal.preferences import load_preferences + + prefs = load_preferences() + for language in ("zh_CN", "en_US"): + set_locale(language) + checker.that(t("app.title") != "app.title", f"{language}: app.title missing") + checker.that(t("axes.title") != "axes.title", f"{language}: axes.title missing") + checker.that( + t("bus.state_ok") != "bus.state_ok", f"{language}: bus.state_ok missing" + ) + set_locale("zh_CN") + + # ---- window ---------------------------------------------------------- + from orisys_autocal.motion import DemoTransport + + for theme in ("dark", "light"): + window = AutoCalMainWindow(prefs={**prefs, "theme": theme}) + window.poller.service._transport = DemoTransport() # noqa: SLF001 + app.setStyleSheet(build_qss(theme)) + print(f"\n--- theme={theme} ---") + + for width, height in RESOLUTIONS: + window.resize(width, height) + window.show() + app.processEvents() + # Every tab, not just the default one: a page that overflows only + # when its tab is selected is exactly the defect this catches. + for key in window.workspace.page_keys(): + window.workspace.set_current_key(key) + app.processEvents() + oversize = [] + for child in _visible_descendants(window): + if _inside_scroll_area(child): + continue # a rail's content is meant to exceed its viewport + 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.width()}x{parent.height()}" + ) + checker.that( + not oversize, f"{theme} {width}x{height} tab={key}: {oversize}" + ) + window.workspace.set_current_key("axes") + print(f" {width}x{height}: every tab fits") + + # ---- the permanent shell ---------------------------------------- + checker.that( + window.workspace.page_keys() == ["axes", "sequence", "sensor", "surface", "points"], + f"unexpected tab set: {window.workspace.page_keys()}", + ) + checker.that( + window.status_strip.isVisible(), + "the motion status strip is not visible", + ) + checker.that( + window.status_strip.height() <= 48, + f"the status strip is {window.status_strip.height()}px tall", + ) + for key in window.workspace.page_keys(): + window.workspace.set_current_key(key) + app.processEvents() + checker.that( + window.status_strip.isVisible(), + f"the status strip vanished on tab {key}", + ) + checker.that( + bool(window.workspace.tabText(window.workspace.currentIndex())), + f"tab {key} has no label", + ) + window.workspace.set_current_key("axes") + + # The readout rail must show all five drives without scrolling. + readout_rows = len(window.axis_readout._rows) + checker.that(readout_rows == 5, f"the readout rail shows {readout_rows} axes") + checker.that( + window.axis_readout.width() >= 200, + f"the readout rail is only {window.axis_readout.width()}px wide", + ) + + # The sequence tab: three zones, a persisted profile, and a point list + # whose coordinates are generated rather than typed. + sequence = window.sequence_panel + checker.that( + sequence.profile_keys() + == [ + "mode", + "pitch_mm", + "margin_mm", + "radius_mm", + "clear_height_mm", + "approach_speed_mm_s", + "press_speed_mm_s", + "press_depth_mm", + "dwell_s", + "settle_window_s", + "repeats", + "order", + ], + f"sequence profile fields changed: {sequence.profile_keys()}", + ) + checker.that( + sequence.validation_checks() + == ["on_surface", "within_travel", "contact_confirmable"], + f"sequence validation checks changed: {sequence.validation_checks()}", + ) + for name, phase in sequence.pending_phases().items(): + button = sequence._pending_buttons[name] + checker.that( + not button.isEnabled() and phase in button.toolTip(), + f"sequence control {name} is usable or does not name its phase", + ) + checker.that( + sequence.planned_steps() == [], + "the sequence tab handed out steps a runner could start", + ) + checker.that( + sequence.point_row_count() > 0, + "the sequence point list is empty", + ) + checker.that( + sequence.table.columnCount() == 8, + f"the point list has {sequence.table.columnCount()} columns, expected 8", + ) + + # The shell: one rail, a run bar, and a health chip. + checker.that( + window.main_splitter.count() == 2, + f"the layout has {window.main_splitter.count()} columns, expected 2", + ) + checker.that( + window.left_rail.widget() is window.rail_content, + "the setup rail is not scrollable", + ) + checker.that( + window.run_bar.isVisible(), + "the run bar is not visible", + ) + checker.that( + not window.run_bar.abort_button.isEnabled(), + "abort is enabled with no run in progress", + ) + for key in window.workspace.page_keys(): + window.workspace.set_current_key(key) + app.processEvents() + checker.that( + window.run_bar.isVisible(), + f"the run bar vanished on tab {key}", + ) + window.workspace.set_current_key("axes") + checker.that( + bool(window.status_strip.chip_health.text()), + "the poll-health chip has no text", + ) + checker.that( + "QStatusBar" in window.statusBar().styleSheet(), + "the status bar is not using its own theme token", + ) + + # A collapsed bus-data group must not cost the page any height. + checker.that( + not window.bus_data_panel.isVisible(), + "the bus-data panel is expanded by default", + ) + print(" shell: strip, readout rail and sequence profile verified") + + panel = window.axis_panel + checker.that(panel.axis_count == 5, "the platform is not five axes") + checker.that( + panel.table.columnCount() == len(COLUMNS) + 1, + "the axis table lost a column", + ) + # Every header must fit its column: a clipped header is how a table stops + # being readable without anyone noticing. + for index in range(panel.table.columnCount()): + header = panel.table.horizontalHeaderItem(index).text() + width = panel.table.columnWidth(index) + needed = panel.table.fontMetrics().horizontalAdvance(header) + 12 + checker.that( + width >= needed, + f"axis column {index} ({header!r}) is {width}px, needs {needed}px", + ) + checker.that( + len(COLUMNS) == 7, + f"the axis table should show seven data columns, has {len(COLUMNS)}", + ) + checker.that( + window.axis_panel._detail_label.text(), + "the selected-axis detail line is empty", + ) + headers = [ + panel.table.horizontalHeaderItem(index).text() + for index in range(panel.table.columnCount()) + ] + checker.that( + all(headers) and headers[0] == t("axes.axis"), + f"axis table headers are not localised: {headers}", + ) + checker.that( + all( + panel.table.item(row, 0) is not None for row in range(panel.axis_count) + ), + "an axis row has no data cell", + ) + + # Motion must be unreachable before a bus is initialised. + checker.that( + not panel._rows[0]["fwd_button"].isEnabled(), + "jog is enabled before the bus is initialised", + ) + + # Bus data follows the reported bus family. ``isEnabledTo`` rather than + # ``isEnabled``: the panel now lives in a collapsed group, and Qt reports + # a hidden ancestor as disabling everything inside it. + box = window.bus_data_group + window.bus_data_panel.set_connected(True) + window.bus_data_panel.set_bus_type("ecat") + checker.that( + window.bus_data_panel.ecat_box.isEnabledTo(box) + and not window.bus_data_panel.rtex_box.isEnabledTo(box), + "the SDO/RTEX panels are not gated by bus type", + ) + + # Expanding the group must reveal the panel, not resize the page. + before = window.size() + box.setChecked(True) + app.processEvents() + checker.that( + window.bus_data_panel.isVisible(), "expanding the group did not show the panel" + ) + box.setChecked(False) + app.processEvents() + checker.that( + window.size() == before, "toggling bus data resized the window" + ) + + # A full simulated session: connect, initialise from the shipped BAS. + window.connect_controller(ConnectionSpec(mode="demo"), label="connect") + _wait_for(app, lambda: window._last_snapshot.connected, 6.0) + checker.that( + window._last_snapshot.connected, "the simulated controller did not connect" + ) + + window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas")) + window._on_download_bas() + ok = _wait_for(app, lambda: window._last_snapshot.bus.init_status == 1, 8.0) + checker.that(ok, "the simulated bus did not report initialised") + if ok: + checker.that( + window._last_snapshot.bus.bus_type == "ecat", + "the bus type was not decoded from BUS_TYPE", + ) + checker.that( + window._last_snapshot.bus.total_axes == 5, + "the bus did not report five axes", + ) + checker.that( + panel._rows[0]["fwd_button"].isEnabled(), + "jog stayed disabled after the bus was initialised", + ) + checker.that( + "65" in window.axis_panel._detail_label.text(), + "ATYPE was not read back on the detail line (expected 65, position mode)", + ) + print( + f" session: bus={window._last_snapshot.bus.bus_type} " + f"axes={window._last_snapshot.bus.total_axes} " + f"{panel.enabled_count_label.text()}" + ) + # The strip must be reporting the live session, from whatever tab. + window.workspace.set_current_key("surface") + app.processEvents() + strip = window.status_strip + checker.that( + strip.chip_connection.text() == t("connection.state_open"), + f"strip connection chip says {strip.chip_connection.text()!r}", + ) + checker.that( + strip.chip_bus.text() == t("bus.type_ecat"), + f"strip bus chip says {strip.chip_bus.text()!r}", + ) + checker.that( + strip.chip_init.text() == t("bus.state_ok"), + f"strip init chip says {strip.chip_init.text()!r}", + ) + checker.that( + t("axes.enabled_count", on=5, total=5) == strip.chip_axes.text() + or "5" in strip.chip_axes.text(), + f"strip axis chip says {strip.chip_axes.text()!r}", + ) + checker.that( + window.axis_readout._rows[0]["position"].text().startswith("0.000"), + f"readout row 0 shows {window.axis_readout._rows[0]['position'].text()!r}", + ) + window.workspace.set_current_key("axes") + + window.close() + app.processEvents() + + return checker.report() + + +def _inside_scroll_area(widget) -> bool: + """Whether ``widget`` is scroll-area content rather than a laid-out child.""" + from PySide6.QtWidgets import QAbstractScrollArea + + parent = widget.parentWidget() + while parent is not None: + if isinstance(parent, QAbstractScrollArea): + return True + parent = parent.parentWidget() + return False + + +def _wait_for(app, predicate, timeout: float) -> bool: + import time + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + app.processEvents() + if predicate(): + return True + time.sleep(0.02) + return False + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/autocal/tools/panel_snapshot.py b/apps/autocal/tools/panel_snapshot.py new file mode 100644 index 0000000..c8e93c0 --- /dev/null +++ b/apps/autocal/tools/panel_snapshot.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Render the AutoCal window offscreen to a PNG, for review. + + python apps/autocal/tools/panel_snapshot.py --theme light --out shot.png + +Optionally drives a simulated session first, so the shot shows live values: + + python apps/autocal/tools/panel_snapshot.py --demo --language en_US --out shot.png +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +APP_ROOT = Path(__file__).resolve().parents[1] +for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +os.environ.setdefault("ORISYS_APP_ID", "autocal") +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) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--theme", default="dark", choices=("dark", "light")) + parser.add_argument("--language", default="zh_CN", choices=("zh_CN", "en_US")) + parser.add_argument("--size", default="1600x900", help="WIDTHxHEIGHT") + parser.add_argument("--out", default="autocal.png") + parser.add_argument( + "--tab", + default="axes", + help="workspace tab to show: axes, sequence, sensor, surface, points", + ) + parser.add_argument( + "--compare", + action="store_true", + help="render every tab, named _., so the layout can be " + "reviewed in one pass", + ) + parser.add_argument( + "--demo", + action="store_true", + help="drive a simulated session so the shot shows live values", + ) + args = parser.parse_args() + width, height = (int(part) for part in args.size.lower().split("x")) + + from PySide6.QtWidgets import QApplication + + from orisys_app_shared.i18n import set_locale + from orisys_app_shared.theme import build_qss + + from orisys_autocal.motion import ConnectionSpec, DemoTransport + from orisys_autocal.preferences import load_preferences + from orisys_autocal.ui.bus_panel import default_bas_dir + from orisys_autocal.ui.window import AutoCalMainWindow + + set_locale(args.language) + app = QApplication.instance() or QApplication([]) + app.setStyleSheet(build_qss(args.theme)) + + prefs = load_preferences() + prefs["theme"] = args.theme + prefs["language"] = args.language + prefs["show_diagnostics"] = False + + window = AutoCalMainWindow(prefs=prefs) + window.poller.service._transport = DemoTransport() # noqa: SLF001 + window.resize(width, height) + window.show() + app.processEvents() + + if args.demo: + window.connect_controller(ConnectionSpec(mode="demo"), label="connect") + _wait_for(app, lambda: window._last_snapshot.connected, 6.0) + window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas")) + window._on_download_bas() + _wait_for(app, lambda: window._last_snapshot.bus.init_status == 1, 8.0) + window.axis_panel.set_selected_axis(0) + window.axis_panel.target_edit.setText("1000") + window._on_move_abs(0, 1000.0) + _wait_for(app, lambda: window._last_snapshot.axis(0).dpos != 0, 4.0) + + app.processEvents() + + if args.compare: + return _render_every_tab(window, app, args, width, height) + + window.workspace.set_current_key(args.tab) + app.processEvents() + output = Path(args.out).resolve() + if not window.grab().save(str(output)): + print(f"could not write {output}", file=sys.stderr) + return 1 + print( + f"wrote {output} ({width}x{height}, theme={args.theme}, " + f"lang={args.language}, tab={args.tab})" + ) + + window.close() + app.processEvents() + return 0 + + +def _render_every_tab(window, app, args, width: int, height: int) -> int: + """One image per workspace tab, so the whole layout can be reviewed at once.""" + base = Path(args.out).resolve() + failures = 0 + for key in window.workspace.page_keys(): + window.workspace.set_current_key(key) + app.processEvents() + target = base.with_name(f"{base.stem}_{key}{base.suffix or '.png'}") + if window.grab().save(str(target)): + print(f"wrote {target} ({width}x{height}, tab={key})") + else: + print(f"could not write {target}", file=sys.stderr) + failures += 1 + window.close() + app.processEvents() + return 1 if failures else 0 + + +def _wait_for(app, predicate, timeout: float) -> bool: + import time + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + app.processEvents() + if predicate(): + return True + time.sleep(0.02) + return False + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/internal/README.md b/apps/internal/README.md index 6d21c7f..0b820a9 100644 --- a/apps/internal/README.md +++ b/apps/internal/README.md @@ -1,5 +1,11 @@ -# Internal app (stub) +# Internal app (superseded) -Future home for automated calibration and laboratory testing tools. +The internal calibration and laboratory tooling this directory was reserved for +now lives in [`apps/autocal`](../autocal/README.md) — the 5-axis calibration +platform control application. -This directory is intentionally empty in the customer-facing scaffold. Shared code will come from `shared/`; do not ship this app in the customer installer. +That app keeps the property this stub existed to protect: it is **not** part of +the customer-facing product. It is not packaged by `build/studio` and must not +enter the customer installer. + +This directory is otherwise unused; it is kept only so the move is documented. diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index f2139b5..fd79bd5 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -33,6 +33,10 @@ "scene3d_settings": "3D display settings…", "show_fps": "Show FPS", "show_diagnostics": "Diagnostics", + "raw_auto_zero": "Finger raw auto-zero at start", + "raw_auto_zero_tip": "Value cards only: estimate an idle bias and deadband after start. Curves, statistics and CSV keep the measured values.", + "raw_reestimate": "Re-estimate raw baseline…", + "raw_reestimate_tip": "Drop the current display baseline and re-estimate from the next quiet samples (value cards only).", "settings": "Settings area", "plot_settings": "Plot settings…", "rotate_arrows": "Rotate arrows 90°", @@ -217,6 +221,8 @@ "selection_empty": "No valid samples in the selection", "value_unavailable": "n/a", "value_unavailable_tip": "No curve is loaded for {channel}, so it has no value in this unit (raw counts are never shown in its place)", + "zero_zone": "zero zone", + "zero_zone_tip": "Display deadband after auto baseline; curves, statistics and CSV keep the measured values", "unit_toggle": "Switch unit", "unit_toggle_tip": "Click to switch the three curves and readouts between newton and raw units", "browse_only_tip": "Drag or scroll on the chart to browse history" @@ -412,6 +418,12 @@ "field_depth": "Depth response", "field_flow": "Optical flow magnitude", "field_substituted": "This frame shows {shown}, not the chosen {chosen}.", + "flow_arrows": "Surface flow arrows", + "flow_arrows_tip": "Draw optical-flow arrows on the sensing surface using the same sample points as the 2D view.", + "flow_arrows_unavailable": "This scene package has no UV→scene surface mapping, so flow arrows cannot be drawn.", + "contact_arrow": "Contact normal arrow", + "contact_arrow_tip": "Draw a yellow local-normal arrow at the contact centre (length from the normal response, capped — not a calibrated force vector).", + "contact_arrow_unavailable": "This scene package has no UV→scene surface mapping, so the contact arrow cannot be drawn.", "quantity_flow_magnitude": "Optical flow magnitude", "quantity_depth_response": "Depth response", "mode_surface": "surface colouring", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index e20fa8c..03f4426 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -33,6 +33,10 @@ "scene3d_settings": "三维显示设置…", "show_fps": "显示 FPS", "show_diagnostics": "诊断信æ�¯", + "raw_auto_zero": "Finger raw å�¯åЍ归零", + "raw_auto_zero_tip": "ä»…å½±å“�æ•°å­—å�¡å±•示:å�¯åЍå�Žè‡ªåŠ¨ä¼°è®¡æ— è½½å��置与死区。曲线ã€�统计与 CSV ä»�为测é‡�值。", + "raw_reestimate": "é‡�新估计 raw 基线…", + "raw_reestimate_tip": "丢弃当å‰�展示基线,从接下æ�¥çš„æ— è½½æ ·æœ¬é‡�新估计(仅数字å�¡ï¼‰ã€‚", "settings": "设置区", "plot_settings": "绘图设置…", "rotate_arrows": "旋转箭头图 90°", @@ -217,6 +221,8 @@ "selection_empty": "选区无有效样本", "value_unavailable": "ä¸�å�¯ç”¨", "value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该å�•ä½�的数值(ä¸�会用原始值冒充)", + "zero_zone": "零区", + "zero_zone_tip": "展示归零å�Žçš„æ­»åŒºå†…读数;曲线ã€�统计与 CSV ä»�为原始测é‡�值", "unit_toggle": "切æ�¢å�•ä½�", "unit_toggle_tip": "点击在「牛顿ã€�与「原始å�•ä½�ã€�之间切æ�¢ä¸‰è½´æ›²çº¿ä¸Žè¯»æ•°", "browse_only_tip": "拖动或滚轮å�³å�¯è¿›å…¥åކå�²æµ�览" @@ -412,6 +418,12 @@ "field_depth": "深度å“�应", "field_flow": "å…‰æµ�幅值", "field_substituted": "这一帧画的是 {shown},ä¸�是所选的 {chosen}。", + "flow_arrows": "表é�¢å…‰æµ�箭头", + "flow_arrows_tip": "在传感表é�¢ä¸ŠæŒ‰ä¸ŽäºŒç»´ç›¸å�Œçš„采样点绘制光æµ�箭头。", + "flow_arrows_unavailable": "当å‰�场景包没有 UV→三维 表é�¢æ˜ å°„,无法绘制光æµ�箭头。", + "contact_arrow": "接触法å�‘箭头", + "contact_arrow_tip": "在接触中心沿局部法å�‘绘制黄色示æ„�箭头(长度由法å�‘å“�应é™�幅,ä¸�是标定å�ˆåŠ›ï¼‰ã€‚", + "contact_arrow_unavailable": "当å‰�场景包没有 UV→三维 表é�¢æ˜ å°„,无法绘制接触法å�‘箭头。", "quantity_flow_magnitude": "å…‰æµ�幅值", "quantity_depth_response": "深度å“�应", "mode_surface": "曲é�¢ç�€è‰²", diff --git a/apps/studio/config/preferences.default.json b/apps/studio/config/preferences.default.json index 16504f3..e7a1df5 100644 --- a/apps/studio/config/preferences.default.json +++ b/apps/studio/config/preferences.default.json @@ -9,6 +9,7 @@ "show_cal_panel": false, "show_finger_3d": true, "show_fps": false, + "raw_auto_zero": true, "gl_profile": "desktop33", "scene3d_package": "", "scene3d_display": { diff --git a/apps/studio/config/sensors/box/force_calibration.json b/apps/studio/config/sensors/box/force_calibration.json index 015fc07..beece03 100644 --- a/apps/studio/config/sensors/box/force_calibration.json +++ b/apps/studio/config/sensors/box/force_calibration.json @@ -1,5 +1,5 @@ { - "schema_version": 1, + "schema_version": 2, "unit": "N", "channels": { "normal": { @@ -14,8 +14,22 @@ [62000.0, 2.377] ] }, - "shear": { + "shear_x": { "method": "piecewise_linear", + "raw_key": "fshearx", + "input": "components", + "signed": true, + "extrapolate": true, + "clamp_output_min": 0.0, + "points": [ + [1000.0, 0.0], + [36000.0, 1.19], + [68000.0, 2.06] + ] + }, + "shear_y": { + "method": "piecewise_linear", + "raw_key": "fsheary", "input": "components", "signed": true, "extrapolate": true, diff --git a/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene b/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene index 773373b..4ce94c9 100644 Binary files a/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene and b/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene differ diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 589689e..7240e54 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -925,6 +925,16 @@ class StudioMainWindow(QMainWindow): ) self.view_menu.addAction(self.act_show_diagnostics) + self.act_raw_auto_zero = QAction(self) + self.act_raw_auto_zero.setCheckable(True) + self.act_raw_auto_zero.setChecked(bool(self.prefs.get("raw_auto_zero", True))) + self.act_raw_auto_zero.toggled.connect(self.display_ctl.on_raw_auto_zero_toggled) + self.view_menu.addAction(self.act_raw_auto_zero) + + self.act_raw_reestimate = QAction(self) + self.act_raw_reestimate.triggered.connect(self.display_ctl.request_raw_reestimate) + self.view_menu.addAction(self.act_raw_reestimate) + self.act_settings = QAction(self) self.act_settings.setCheckable(True) self.act_settings.setChecked(self.toolbar.settings_expanded()) @@ -1168,6 +1178,10 @@ class StudioMainWindow(QMainWindow): self.act_scene3d_settings.setText(t("menu.scene3d_settings")) self.act_show_fps.setText(t("menu.show_fps")) self.act_show_diagnostics.setText(t("menu.show_diagnostics")) + self.act_raw_auto_zero.setText(t("menu.raw_auto_zero")) + self.act_raw_auto_zero.setToolTip(t("menu.raw_auto_zero_tip")) + self.act_raw_reestimate.setText(t("menu.raw_reestimate")) + self.act_raw_reestimate.setToolTip(t("menu.raw_reestimate_tip")) self.act_settings.setText(t("menu.settings")) self.act_plot_settings.setText(t("menu.plot_settings")) self.act_rotate_arrows.setText(t("menu.rotate_arrows")) @@ -1886,6 +1900,16 @@ class StudioMainWindow(QMainWindow): self._stop_preview(announce=False) self.force_plots.reset() + units = "N" if self.toolbar.units() == "newton" else "raw" + self.force_plots.configure_presentation( + sensor_type=sensor_type, + unit=units, + enabled=( + sensor_type == "finger" + and units == "raw" + and bool(self.prefs.get("raw_auto_zero", True)) + ), + ) self._last_plot_packet_key = None self._last_rendered_view_mode = None # Fresh record channel for the new session. The calibration identity is @@ -2126,6 +2150,30 @@ class StudioMainWindow(QMainWindow): self.session.last_sample_valid = True self._last_display_fallback = bool(display.using_raw_fallback) self._last_display_available = tuple(bool(flag) for flag in display.available) + flow = getattr(packet, "flow", None) + flow_p95 = None + if flow is not None: + try: + import numpy as np + + arr = np.asarray(flow, dtype=np.float64) + if arr.ndim == 3 and arr.shape[-1] == 2: + mag = np.linalg.norm(arr, axis=-1) + mask = getattr(packet, "valid_mask", None) + if mask is not None: + m = np.asarray(mask, dtype=bool) + if m.shape == mag.shape: + vals = mag[m & np.isfinite(mag)] + else: + vals = mag[np.isfinite(mag)] + else: + vals = mag[np.isfinite(mag)] + if vals.size: + flow_p95 = float(np.percentile(vals, 95)) + except Exception: # noqa: BLE001 + flow_p95 = None + contact = getattr(packet, "contact", None) + contact_found = bool(getattr(contact, "found", False)) if contact else False self.force_plots.push( # Session-relative time, the same base the store and # ``forces.csv`` use. The wall clock must never reach the @@ -2136,6 +2184,9 @@ class StudioMainWindow(QMainWindow): display.values[2], unit=display.unit, available=display.available, + flow_p95=flow_p95, + contact_found=contact_found, + forces_valid=bool(getattr(packet, "forces_valid", True)), ) else: self.session.last_sample_valid = False @@ -2226,6 +2277,11 @@ class StudioMainWindow(QMainWindow): def _render_run_image(self, packet) -> None: if packet is None or self._replay_owns_image(): return + flow_valid = getattr(packet, "flow_valid", None) + depth_valid = getattr(packet, "depth_valid", None) + stale = bool(getattr(packet, "stale_forces", False)) or ( + flow_valid is False and depth_valid is False + ) self._show_image_frame( ImageFrame( getattr(packet, "processed_frame", None), @@ -2235,6 +2291,11 @@ class StudioMainWindow(QMainWindow): source=SOURCE_RECOMPUTE if self._recompute_running else SOURCE_LIVE, frame_id=int(getattr(packet, "frame_id", 0) or 0), t_rel_s=float(getattr(packet, "mono_t", 0.0) or 0.0), + stale=stale, + flow_valid=flow_valid, + flow_frame_id=getattr(packet, "flow_frame_id", None), + depth_valid=depth_valid, + depth_frame_id=getattr(packet, "depth_frame_id", None), ) ) diff --git a/apps/studio/src/orisys_studio/display_prefs.py b/apps/studio/src/orisys_studio/display_prefs.py index 217b833..5cb2759 100644 --- a/apps/studio/src/orisys_studio/display_prefs.py +++ b/apps/studio/src/orisys_studio/display_prefs.py @@ -84,7 +84,10 @@ class DisplayPrefsController(QObject): #: Whether the panels explain the pipeline around the picture (see #: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*. self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False)) + #: Finger-raw value cards: startup auto baseline / deadband (display only). + self._raw_auto_zero = bool(host.prefs.get("raw_auto_zero", True)) self._apply_diagnostics() + self.apply_presentation_prefs() def bind_actions(self, *, rotate_arrows: Any) -> None: """Take the rotate entry once the menus exist (it follows the sensor).""" @@ -261,3 +264,43 @@ class DisplayPrefsController(QObject): setter = getattr(panel, "set_diagnostics_visible", None) if callable(setter): setter(self._show_diagnostics) + + # ------------------------------------------------------- raw display zeroing + @property + def raw_auto_zero(self) -> bool: + return bool(self._raw_auto_zero) + + def set_raw_auto_zero(self, enabled: bool) -> None: + """Persist whether Finger raw value cards auto-baseline at session start.""" + self._raw_auto_zero = bool(enabled) + self._host.prefs["raw_auto_zero"] = self._raw_auto_zero + self._host._persist_prefs() + self.apply_presentation_prefs() + + def on_raw_auto_zero_toggled(self, enabled: bool) -> None: + self.set_raw_auto_zero(enabled) + + def apply_presentation_prefs(self) -> None: + """Push the current baseline policy onto the charts (Finger raw only).""" + configure = getattr(self._charts, "configure_presentation", None) + if not callable(configure): + return + sensor = ( + self._picker.sensor_type() + if hasattr(self._picker, "sensor_type") + else "box" + ) + unit = "N" if self._toolbar.units() == "newton" else "raw" + configure( + sensor_type=sensor, + unit=unit, + enabled=bool(self._raw_auto_zero and sensor == "finger" and unit == "raw"), + ) + + def request_raw_reestimate(self) -> None: + """Operator entry: restart the idle baseline window on the value cards.""" + request = getattr(self._charts, "request_presentation_reestimate", None) + if callable(request): + request() + else: + self.apply_presentation_prefs() diff --git a/apps/studio/src/orisys_studio/scene3d/adapters.py b/apps/studio/src/orisys_studio/scene3d/adapters.py index 890d765..2009003 100644 --- a/apps/studio/src/orisys_studio/scene3d/adapters.py +++ b/apps/studio/src/orisys_studio/scene3d/adapters.py @@ -392,6 +392,26 @@ class FingerFrameAdapter: refused.append(mask_problem) return None self.last_diagnostic = None + contact = getattr(packet, "contact", None) + contact_xy = None + contact_found = False + contact_valid = False + if contact is not None: + contact_xy = getattr(contact, "centroid_uv_xy", None) + contact_found = bool(getattr(contact, "found", False)) + contact_valid = bool(getattr(contact, "valid", False)) + flow = getattr(packet, "flow", None) + flow_uv = None + flow_mask = None + if flow is not None and _field_validity(packet, "flow", identity.frame_id) is None: + arr = np.asarray(flow, dtype=np.float32) + if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw: + flow_uv = freeze(arr, dtype=np.float32) + device_for_flow, _ = _device_mask(packet, self.shape_hw) + flow_mask = _intersect(np.isfinite(arr[..., 0]) & np.isfinite(arr[..., 1]), device_for_flow) + fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0) + if not np.isfinite(fn): + fn = float(getattr(packet, "normal_force", 0.0) or 0.0) return SurfaceFieldFrame( identity=identity, geometry_id=self.geometry_id, @@ -400,6 +420,12 @@ class FingerFrameAdapter: scalar_descriptor=descriptor, valid=_intersect(mask, device), computed_frame_id=_computed_frame_id(packet, name, identity.frame_id), + flow_uv=flow_uv, + flow_valid_mask=flow_mask, + contact_uv_xy=contact_xy if isinstance(contact_xy, tuple) else None, + contact_found=contact_found, + contact_valid=contact_valid, + contact_fn=fn if np.isfinite(fn) else 0.0, ) diff --git a/apps/studio/src/orisys_studio/scene3d/coordinator.py b/apps/studio/src/orisys_studio/scene3d/coordinator.py index 7dcbac1..97d0da5 100644 --- a/apps/studio/src/orisys_studio/scene3d/coordinator.py +++ b/apps/studio/src/orisys_studio/scene3d/coordinator.py @@ -462,17 +462,24 @@ class ThreeDCoordinator: self._set_state("no_data") def set_settings(self, settings: DisplaySettings) -> None: - """Apply display settings; a gain change means recomposing the last field.""" + """Apply display settings; a gain/overlay change recomposes the last field.""" previous = self._settings self._settings = settings self._widget.set_settings(settings) - gain_changed = ( + needs_recompose = ( previous.display_gain != settings.display_gain or previous.deform_enabled != settings.deform_enabled + or previous.field != settings.field + or previous.flow_arrows_enabled != settings.flow_arrows_enabled + or previous.contact_arrow_enabled != settings.contact_arrow_enabled + or previous.flow_on_px != settings.flow_on_px + or previous.flow_off_px != settings.flow_off_px + or previous.flow_grid_spacing != settings.flow_grid_spacing + or previous.max_arrows != settings.max_arrows ) - if gain_changed and self._assets is not None: - # The scalar is untouched by the gain, but the *positions* are not, so - # the last field frame has to be composed again. + if needs_recompose and self._assets is not None: + # Gain moves positions; overlay toggles rebuild arrow instances on the + # held field frame so the operator sees the change without waiting. self._settings_dirty = True def show_rest(self) -> None: diff --git a/apps/studio/src/orisys_studio/scene3d/gl_backend.py b/apps/studio/src/orisys_studio/scene3d/gl_backend.py index 147c5e8..9e70077 100644 --- a/apps/studio/src/orisys_studio/scene3d/gl_backend.py +++ b/apps/studio/src/orisys_studio/scene3d/gl_backend.py @@ -190,6 +190,31 @@ def shader_header(profile: Profile) -> str: raise ValueError(f"unknown GL profile: {profile!r}") from None +_ARROW_VERTEX_BODY = """ +layout(location = 0) in vec3 a_position; +layout(location = 1) in vec4 a_color; + +uniform mat4 u_view; +uniform mat4 u_projection; + +out vec4 v_color; + +void main() { + v_color = a_color; + gl_Position = u_projection * u_view * vec4(a_position, 1.0); +} +""" + +_ARROW_FRAGMENT_BODY = """ +in vec4 v_color; +out vec4 frag_color; + +void main() { + frag_color = v_color; +} +""" + + def shader_source(profile: Profile, program: str) -> tuple[str, str]: """``(vertex, fragment)`` source for a profile and a program name. @@ -199,19 +224,23 @@ def shader_source(profile: Profile, program: str) -> tuple[str, str]: header = shader_header(profile) if program == "scalar": body = _SCALAR_FRAGMENT_BODY + vertex = _VERTEX_BODY elif program == "diffuse": body = _DIFFUSE_FRAGMENT_BODY + vertex = _VERTEX_BODY + elif program == "arrow": + return header + _ARROW_VERTEX_BODY, header + _ARROW_FRAGMENT_BODY else: raise ValueError(f"unknown shader program: {program!r}") - return header + _VERTEX_BODY, header + body + return header + vertex, header + body def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]: - """The range of the *valid* samples, or ``None`` when there are none. + """Robust P5/P95 range of *valid* samples, or ``None`` when there are none. - Only valid and finite samples count. Including the invalid ones would make the - colour range follow whatever garbage sits outside the UV island, and including - NaN would produce a range of NaN and a picture with no colours at all. + Using min/max made empty-field noise paint the whole LUT. A percentile span + with a minimum width keeps weak signals visible without amplifying floor + noise into a full contact. """ import numpy as np @@ -222,9 +251,39 @@ def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]: usable = values[mask & np.isfinite(values)] if usable.size == 0: return None - return float(usable.min()), float(usable.max()) + if usable.size < 8: + lower = float(usable.min()) + upper = float(usable.max()) + else: + lower = float(np.percentile(usable, 5.0)) + upper = float(np.percentile(usable, 95.0)) + # Minimum span: avoid collapsing nearly-constant fields into one colour. + span = upper - lower + min_span = max(abs(lower) * 0.05, abs(upper) * 0.05, 1e-3) + if span < min_span: + mid = 0.5 * (lower + upper) + lower = mid - 0.5 * min_span + upper = mid + 0.5 * min_span + return lower, upper +def _blend_scale_range( + previous: Optional[tuple[float, float]], + current: Optional[tuple[float, float]], + *, + alpha: float = 0.15, +) -> Optional[tuple[float, float]]: + """Slowly update an automatic colour range to avoid frame-to-frame flicker.""" + if current is None: + return previous + if previous is None: + return current + a = float(np.clip(alpha, 0.0, 1.0)) + return ( + (1.0 - a) * float(previous[0]) + a * float(current[0]), + (1.0 - a) * float(previous[1]) + a * float(current[1]), + ) + def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[float, float]: """The colour range to draw with: the operator's, or the data's own. @@ -514,6 +573,10 @@ class GLRenderer: self._initialized = False self._paint_calls = 0 self._last_upload_ms = 0.0 + #: Overlay VAOs keyed by kind ("flow" / "contact"). + self._overlay_vaos: dict[str, tuple[int, int, int, int]] = {} + self._arrow_program: int = 0 + self._arrow_uniforms: dict[str, int] = {} # ------------------------------------------------------------------ lifetime def initialize(self, capabilities: GLCapabilities) -> None: @@ -529,6 +592,7 @@ class GLRenderer: try: self._programs["scalar"] = _compile_program(GL, capabilities.profile, "scalar") self._programs["diffuse"] = _compile_program(GL, capabilities.profile, "diffuse") + self._programs["arrow"] = _compile_program(GL, capabilities.profile, "arrow") except Exception as exc: # noqa: BLE001 self.release() raise error( @@ -604,7 +668,74 @@ class GLRenderer: GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) self._last_upload_ms = (time.perf_counter() - started) * 1000.0 - self._scalar_range = _valid_scalar_range(frame.scalar, frame.valid) + measured = _valid_scalar_range(frame.scalar, frame.valid) + self._scalar_range = _blend_scale_range(self._scalar_range, measured) + self._upload_overlays(frame) + + def _upload_overlays(self, frame: RenderFrame) -> None: + """Upload merged flow / contact arrow meshes for this frame.""" + from orisys_app_shared.scene3d.overlays import arrow_instances_to_mesh + + for kind, instances in ( + ("flow", getattr(frame, "flow_arrows", None)), + ("contact", getattr(frame, "contact_arrow", None)), + ): + if instances is None or int(instances.origins.shape[0]) == 0: + self._clear_overlay(kind) + continue + positions, triangles, colors = arrow_instances_to_mesh(instances) + if positions.size == 0: + self._clear_overlay(kind) + continue + self._set_overlay(kind, positions, triangles, colors) + + def _set_overlay( + self, + kind: str, + positions: np.ndarray, + triangles: np.ndarray, + colors: np.ndarray, + ) -> None: + from OpenGL import GL + + self._clear_overlay(kind) + count = int(positions.shape[0]) + interleaved = np.empty((count, 7), dtype=np.float32) + interleaved[:, 0:3] = positions + interleaved[:, 3:7] = colors + vao = int(GL.glGenVertexArrays(1)) + vbo = int(GL.glGenBuffers(1)) + ebo = int(GL.glGenBuffers(1)) + GL.glBindVertexArray(vao) + GL.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo) + GL.glBufferData( + GL.GL_ARRAY_BUFFER, interleaved.nbytes, interleaved, GL.GL_DYNAMIC_DRAW + ) + GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, ebo) + GL.glBufferData( + GL.GL_ELEMENT_ARRAY_BUFFER, triangles.nbytes, triangles, GL.GL_DYNAMIC_DRAW + ) + stride = 7 * 4 + GL.glEnableVertexAttribArray(0) + GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None) + GL.glEnableVertexAttribArray(1) + GL.glVertexAttribPointer(1, 4, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3)) + GL.glBindVertexArray(0) + self._overlay_vaos[kind] = (vao, vbo, ebo, int(triangles.size)) + + def _clear_overlay(self, kind: str) -> None: + from OpenGL import GL + + entry = self._overlay_vaos.pop(kind, None) + if entry is None: + return + vao, vbo, ebo, _ = entry + try: + GL.glDeleteVertexArrays(1, [int(vao)]) + GL.glDeleteBuffers(1, [int(vbo)]) + GL.glDeleteBuffers(1, [int(ebo)]) + except Exception: # noqa: BLE001 + pass def draw( self, @@ -659,6 +790,27 @@ class GLRenderer: GL.GL_TRIANGLES, part.triangles * 3, GL.GL_UNSIGNED_INT, None ) GL.glBindVertexArray(0) + # Overlays after the surface so arrows stay visible. + arrow_program = self._programs.get("arrow") + if arrow_program and self._overlay_vaos: + locations = self._uniforms.get("arrow", {}) + GL.glUseProgram(arrow_program) + _set_mat4(GL, locations.get("u_view"), view) + _set_mat4(GL, locations.get("u_projection"), projection) + GL.glDisable(GL.GL_DEPTH_TEST) + for kind in ("flow", "contact"): + entry = self._overlay_vaos.get(kind) + if entry is None: + continue + vao, _vbo, _ebo, index_count = entry + if index_count <= 0: + continue + GL.glBindVertexArray(vao) + GL.glDrawElements( + GL.GL_TRIANGLES, int(index_count), GL.GL_UNSIGNED_INT, None + ) + GL.glBindVertexArray(0) + GL.glEnable(GL.GL_DEPTH_TEST) GL.glUseProgram(0) self._paint_calls += 1 return RenderStats( @@ -687,6 +839,8 @@ class GLRenderer: from OpenGL import GL self.release_parts() + for kind in list(self._overlay_vaos): + self._clear_overlay(kind) for program in self._programs.values(): try: GL.glDeleteProgram(int(program)) diff --git a/apps/studio/src/orisys_studio/scene3d/panel.py b/apps/studio/src/orisys_studio/scene3d/panel.py index 65f958b..7df114f 100644 --- a/apps/studio/src/orisys_studio/scene3d/panel.py +++ b/apps/studio/src/orisys_studio/scene3d/panel.py @@ -222,6 +222,16 @@ class ThreeDPanel(QFrame): self.field_note.setVisible(False) form.addRow(self.field_note) + self.flow_arrows_check = QCheckBox() + self.flow_arrows_check.setChecked(True) + self.flow_arrows_check.toggled.connect(self._emit_settings) + form.addRow(self.flow_arrows_check) + + self.contact_arrow_check = QCheckBox() + self.contact_arrow_check.setChecked(True) + self.contact_arrow_check.toggled.connect(self._emit_settings) + form.addRow(self.contact_arrow_check) + self.scale_label = QLabel() self.scale_combo = QComboBox() self.scale_combo.addItem("fixed", "fixed") @@ -403,6 +413,18 @@ class ThreeDPanel(QFrame): self.deform_check.setChecked(False) finally: self._syncing = False + flow_ok = bool(capabilities and capabilities.flow_arrows_available) + contact_ok = bool(capabilities and capabilities.contact_arrow_available) + self.flow_arrows_check.setEnabled(flow_ok and enabled) + self.contact_arrow_check.setEnabled(contact_ok and enabled) + self.flow_arrows_check.setToolTip( + t("scene3d.flow_arrows_unavailable") if not flow_ok else t("scene3d.flow_arrows_tip") + ) + self.contact_arrow_check.setToolTip( + t("scene3d.contact_arrow_unavailable") + if not contact_ok + else t("scene3d.contact_arrow_tip") + ) def capabilities(self) -> Optional[SceneCapabilities]: return self._capabilities @@ -604,6 +626,8 @@ class ThreeDPanel(QFrame): self.housing_check, self.deform_check, self.gain_spin, + self.flow_arrows_check, + self.contact_arrow_check, self.scale_combo, self.fps_combo, self.reset_button, @@ -635,6 +659,8 @@ class ThreeDPanel(QFrame): index = self.field_combo.findData(str(settings.field)) if index >= 0: self.field_combo.setCurrentIndex(index) + self.flow_arrows_check.setChecked(bool(settings.flow_arrows_enabled)) + self.contact_arrow_check.setChecked(bool(settings.contact_arrow_enabled)) scale = settings.color_scale if scale is not None: index = self.scale_combo.findData(str(scale.mode)) @@ -678,6 +704,8 @@ class ThreeDPanel(QFrame): housing_visible=self.housing_check.isChecked(), max_fps=int(self.fps_combo.currentData()), field=self.field(), + flow_arrows_enabled=bool(self.flow_arrows_check.isChecked()), + contact_arrow_enabled=bool(self.contact_arrow_check.isChecked()), ) # ------------------------------------------------------------------ signals @@ -730,6 +758,8 @@ class ThreeDPanel(QFrame): self.field_combo.setItemText(0, t("scene3d.field_depth")) self.field_combo.setItemText(1, t("scene3d.field_flow")) self._render_field_note() + self.flow_arrows_check.setText(t("scene3d.flow_arrows")) + self.contact_arrow_check.setText(t("scene3d.contact_arrow")) self.scale_label.setText(t("scene3d.scale")) self.range_label.setText(t("scene3d.scale_range")) self.range_label.setToolTip(t("scene3d.scale_range_tip")) diff --git a/apps/studio/src/orisys_studio/scene3d/prefs.py b/apps/studio/src/orisys_studio/scene3d/prefs.py index 0501706..b7041b0 100644 --- a/apps/studio/src/orisys_studio/scene3d/prefs.py +++ b/apps/studio/src/orisys_studio/scene3d/prefs.py @@ -80,6 +80,12 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]: "housing_visible": bool(settings.housing_visible), "max_fps": int(settings.max_fps), "field": str(settings.field), + "flow_arrows_enabled": bool(settings.flow_arrows_enabled), + "contact_arrow_enabled": bool(settings.contact_arrow_enabled), + "flow_on_px": float(settings.flow_on_px), + "flow_off_px": float(settings.flow_off_px), + "flow_grid_spacing": int(settings.flow_grid_spacing), + "max_arrows": int(settings.max_arrows), } if scale is not None: block["color_scale"] = { @@ -138,6 +144,16 @@ def settings_from_prefs( lut_id=str(saved.get("lut_id") or "sequential"), mode=mode, # type: ignore[arg-type] ) + flow_on = max(0.0, _number(raw.get("flow_on_px"), 0.4)) + flow_off = max(0.0, _number(raw.get("flow_off_px"), 0.3)) + if flow_on < flow_off: + flow_on, flow_off = flow_off, flow_on + spacing = int(_number(raw.get("flow_grid_spacing"), 10)) + if spacing < 1: + spacing = 10 + max_arrows = int(_number(raw.get("max_arrows"), 400)) + if max_arrows < 1: + max_arrows = 400 return DisplaySettings( display_gain=gain, deform_enabled=_flag(raw.get("deform_enabled"), False), @@ -145,4 +161,10 @@ def settings_from_prefs( housing_visible=_flag(raw.get("housing_visible"), True), max_fps=max_fps, field=field, + flow_arrows_enabled=_flag(raw.get("flow_arrows_enabled"), True), + contact_arrow_enabled=_flag(raw.get("contact_arrow_enabled"), True), + flow_on_px=flow_on, + flow_off_px=flow_off, + flow_grid_spacing=spacing, + max_arrows=max_arrows, ) diff --git a/apps/studio/src/orisys_studio/scene3d/view.py b/apps/studio/src/orisys_studio/scene3d/view.py index 51d7ed0..e52886b 100644 --- a/apps/studio/src/orisys_studio/scene3d/view.py +++ b/apps/studio/src/orisys_studio/scene3d/view.py @@ -415,8 +415,10 @@ class Scene3DView(QWidget): """ adapter = self._adapter descriptor = self._last_descriptor - if adapter is None and descriptor is None: + if adapter is None and descriptor is None and self._assets is None: return None + mapping = getattr(self._assets, "surface_mapping", None) if self._assets else None + overlays_ok = mapping is not None return SceneCapabilities( quantity=str( getattr(adapter, "quantity", "") or getattr(descriptor, "quantity", "") @@ -427,6 +429,8 @@ class Scene3DView(QWidget): ), displacement_quality=None, source_kind=str(self._source_kind), + flow_arrows_available=overlays_ok, + contact_arrow_available=overlays_ok, ) def _publish_capabilities(self) -> None: diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index 6a72b17..ca2564d 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -18,6 +18,7 @@ from PyQt5.QtWidgets import ( from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ from orisys_app_shared.force import MetricHistory from orisys_app_shared.force.history import DEFAULT_MAX_FPS +from orisys_app_shared.force.presentation import ForcePresentation from orisys_app_shared.force.source import StoreCurveSource from orisys_app_shared.i18n import t from orisys_app_shared.theme import color @@ -70,8 +71,12 @@ def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]] return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)] -def format_force(value: float, unit: str) -> str: +def format_force( + value: float, unit: str, *, presentation: ForcePresentation | None = None +) -> str: """Render one force reading for the value cards.""" + if presentation is not None: + return presentation.format_force(value, unit=unit) if unit == "N": return f"{value:.3f}" return f"{value:.1f}" @@ -137,6 +142,11 @@ class ForcePlotsPanel(QFrame): self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0) #: Per axis: does ``_last_values`` hold a real value in the active unit? self._available: tuple[bool, bool, bool] = (True, True, True) + #: Display-only baseline / deadband for Finger raw value cards. + self.presentation = ForcePresentation(enabled=False) + self._sensor_type = "box" + self._display_values: tuple[float, float, float] = (0.0, 0.0, 0.0) + self._display_suppressed: tuple[bool, bool, bool] = (False, False, False) # -- browsing state ------------------------------------------------- #: Where the curves read from: the in-memory session store here, a #: recording on disk in the replay. One interface either way. @@ -484,7 +494,9 @@ class ForcePlotsPanel(QFrame): self.history.reset() self._clear_curves() self._last_values = (0.0, 0.0, 0.0) + self._display_values = (0.0, 0.0, 0.0) self._available = (True, True, True) + self.presentation.reset_session(unit=self._unit) self._unit = "raw" self._mode = "follow" self._window = (0.0, 0.0) @@ -500,6 +512,49 @@ class ForcePlotsPanel(QFrame): self._apply_axis_mode() self._refresh_mode_label() + def configure_presentation( + self, + *, + sensor_type: str, + unit: str, + enabled: bool | None = None, + ) -> None: + """Enable Finger-raw auto baseline for value cards only.""" + self._sensor_type = str(sensor_type or "box") + use = ( + bool(enabled) + if enabled is not None + else (self._sensor_type == "finger" and unit != "N") + ) + self.presentation.enabled = use + self.presentation.reset_session(unit=unit) + + def request_presentation_reestimate(self) -> None: + """Restart the idle baseline window (display cards only).""" + self.presentation.request_reestimate() + self._display_values = self._last_values + self._render_value_cards() + + def observe_for_baseline( + self, + values: tuple[float, float, float], + *, + t_rel_s: float, + valid: bool = True, + flow_p95: float | None = None, + contact_found: bool = False, + ) -> None: + """Feed the startup estimator; curves keep the raw measurement.""" + if not self.presentation.enabled: + return + self.presentation.observe( + values, + t_rel_s=t_rel_s, + valid=valid, + flow_p95=flow_p95, + contact_found=contact_found, + ) + def push( self, t_rel_s: float, @@ -509,6 +564,9 @@ class ForcePlotsPanel(QFrame): *, unit: str = "raw", available: tuple[bool, bool, bool] | None = None, + flow_p95: float | None = None, + contact_found: bool = False, + forces_valid: bool = True, ) -> None: """Append one live sample. @@ -532,6 +590,18 @@ class ForcePlotsPanel(QFrame): self._last_values = (float(fn), float(fx), float(fy)) if available is not None: self._available = tuple(bool(flag) for flag in available) + self.observe_for_baseline( + self._last_values, + t_rel_s=float(t_rel_s), + valid=bool(forces_valid), + flow_p95=flow_p95, + contact_found=contact_found, + ) + displayed = self.presentation.accept( + self._last_values, unit=unit, available=self._available + ) + self._display_values = displayed.values + self._display_suppressed = displayed.suppressed if unit != self._unit: # A unit change mid-stream is a *switch*, never a reinterpretation: # the buffered values were measured in the old unit and must not be @@ -1182,6 +1252,8 @@ class ForcePlotsPanel(QFrame): peak = float(selecting.peaks[index]) available = bool(np.isfinite(peak)) value = peak + elif self.presentation.enabled and self.presentation.ready: + value = self._display_values[index] if not available: # An axis with no curve in this unit: a dash, never a raw count # presented as a newton value. @@ -1192,14 +1264,30 @@ class ForcePlotsPanel(QFrame): t("chart.value_unavailable_tip", channel=CHANNEL_LABELS[index]) ) continue - value_label.setText(fmt.format(value=value)) - value_label.setToolTip( - t("chart.selection_peak_tip") if selecting is not None else "" + value_label.setText( + format_force(value, self._unit, presentation=self.presentation) + if selecting is None + else fmt.format(value=value) ) + tip = t("chart.selection_peak_tip") if selecting is not None else "" + zero_zone = ( + selecting is None + and self.presentation.enabled + and self.presentation.ready + and abs(float(value)) == 0.0 + and index < len(self._display_suppressed) + and self._display_suppressed[index] + ) + if zero_zone: + tip = t("chart.zero_zone_tip") + value_label.setToolTip(tip) unit_label.setText(unit_display(self._unit)) - detail_label.setText( - self._selection_detail(index) if selecting is not None else "" - ) + if selecting is not None: + detail_label.setText(self._selection_detail(index)) + elif zero_zone: + detail_label.setText(t("chart.zero_zone")) + else: + detail_label.setText("") self.unit_badge.setText(unit_display(self._unit)) def _selection_detail(self, index: int) -> str: diff --git a/apps/studio/tests/test_scene3d_overlays.py b/apps/studio/tests/test_scene3d_overlays.py new file mode 100644 index 0000000..090b82b --- /dev/null +++ b/apps/studio/tests/test_scene3d_overlays.py @@ -0,0 +1,115 @@ +"""Composer overlays and 2D/3D sample-point parity.""" + +from __future__ import annotations + +import numpy as np + +from orisys_app_shared.acquisition.arrows import _policy_from_style +from orisys_app_shared.scene3d.composer import SceneComposer +from orisys_app_shared.scene3d.contracts import ( + DisplaySettings, + FieldDescriptor, + SurfaceFieldFrame, + freeze, +) +from orisys_app_shared.scene3d.synthetic import poc_identity, poc_scene +from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples + + +def _depth_frame(assets, *, flow=None, contact_xy=None, fn=0.0): + h, w = assets.shape_hw + scalar = freeze(np.zeros((h, w), dtype=np.float32), dtype=np.float32) + valid = freeze(np.ones((h, w), dtype=bool), dtype=np.bool_) + flow_uv = None if flow is None else freeze(np.asarray(flow, dtype=np.float32), dtype=np.float32) + flow_mask = None if flow is None else freeze(np.asarray(valid, dtype=bool).copy(), dtype=np.bool_) + found = contact_xy is not None + return SurfaceFieldFrame( + identity=poc_identity(), + geometry_id=assets.geometry_id, + field_domain_id=assets.field_domain_id, + scalar=scalar, + valid=valid, + scalar_descriptor=FieldDescriptor( + "depth_response", "1", "approximate", False + ), + computed_frame_id=0, + flow_uv=flow_uv, + flow_valid_mask=flow_mask, + contact_uv_xy=None if contact_xy is None else tuple(contact_xy), + contact_found=found, + contact_valid=found, + contact_fn=float(fn), + ) + + +def test_composer_builds_overlays_when_mapping_present() -> None: + assets = poc_scene() + h, w = assets.shape_hw + flow = np.zeros((h, w, 2), dtype=np.float32) + flow[1, 1] = (0.9, 0.0) + frame = _depth_frame(assets, flow=flow, contact_xy=(1.0, 1.0), fn=800.0) + composed = SceneComposer().compose( + assets, + frame, + DisplaySettings( + flow_arrows_enabled=True, + contact_arrow_enabled=True, + flow_grid_spacing=1, + flow_on_px=0.4, + flow_off_px=0.3, + max_arrows=50, + ), + ) + assert composed.flow_arrows is not None + assert composed.flow_arrows.origins.shape[0] >= 1 + assert composed.contact_arrow is not None + assert composed.contact_arrow.origins.shape[0] == 1 + + +def test_composer_hides_overlays_when_disabled() -> None: + assets = poc_scene() + h, w = assets.shape_hw + flow = np.zeros((h, w, 2), dtype=np.float32) + flow[1, 1] = (0.9, 0.0) + frame = _depth_frame(assets, flow=flow, contact_xy=(1.0, 1.0), fn=800.0) + composed = SceneComposer().compose( + assets, + frame, + DisplaySettings(flow_arrows_enabled=False, contact_arrow_enabled=False), + ) + assert composed.flow_arrows is None + assert composed.contact_arrow is None + + +def test_2d_3d_flow_sample_points_match() -> None: + assets = poc_scene() + h, w = assets.shape_hw + flow = np.zeros((h, w, 2), dtype=np.float64) + flow[1, 1] = (0.7, 0.2) + mask = np.ones((h, w), dtype=bool) + settings = DisplaySettings( + flow_on_px=0.4, + flow_off_px=0.3, + flow_grid_spacing=1, + max_arrows=50, + ) + policy_3d = _policy_from_style( + "finger", + flow_on_px=settings.flow_on_px, + flow_off_px=settings.flow_off_px, + flow_grid_spacing=settings.flow_grid_spacing, + max_arrows=settings.max_arrows, + ) + policy_shared = VisualPolicy( + flow_on_px=settings.flow_on_px, + flow_off_px=settings.flow_off_px, + flow_grid_spacing=settings.flow_grid_spacing, + max_arrows=settings.max_arrows, + flow_edge_margin=0, + ).normalised() + samples_2d = select_flow_samples(flow, mask, policy_shared) + samples_3d = select_flow_samples(flow, mask, policy_shared) + assert list(zip(samples_2d.rows, samples_2d.cols)) == list( + zip(samples_3d.rows, samples_3d.cols) + ) + assert policy_3d.flow_on_px == settings.flow_on_px diff --git a/apps/studio/tests/test_scene3d_view.py b/apps/studio/tests/test_scene3d_view.py index 8784fcc..495f43a 100644 --- a/apps/studio/tests/test_scene3d_view.py +++ b/apps/studio/tests/test_scene3d_view.py @@ -433,6 +433,12 @@ def test_the_display_settings_round_trip(): deform_enabled=True, housing_visible=False, max_fps=60, + flow_arrows_enabled=False, + contact_arrow_enabled=True, + flow_on_px=0.55, + flow_off_px=0.35, + flow_grid_spacing=12, + max_arrows=250, color_scale=ColorScale( descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False), lower=0.25, @@ -446,6 +452,12 @@ def test_the_display_settings_round_trip(): assert restored.deform_enabled is True assert restored.housing_visible is False assert restored.max_fps == 60 + assert restored.flow_arrows_enabled is False + assert restored.contact_arrow_enabled is True + assert restored.flow_on_px == 0.55 + assert restored.flow_off_px == 0.35 + assert restored.flow_grid_spacing == 12 + assert restored.max_arrows == 250 assert restored.color_scale is not None assert (restored.color_scale.lower, restored.color_scale.upper) == (0.25, 4.5) assert restored.color_scale.mode == "fixed" diff --git a/build/README.md b/build/README.md index dce2bfc..bff14d8 100644 --- a/build/README.md +++ b/build/README.md @@ -3,6 +3,11 @@ Windows packaging for the studio desktop app: **one provisioning step per machine, then repeatable builds.** +> **AutoCal is not built here.** `apps/autocal` (the internal 5-axis platform +> control app) runs from source and has no PyInstaller spec, deliberately: this +> spec would otherwise have to learn about the ZMotion runtime DLLs, and AutoCal +> must stay out of the customer installer. See `apps/autocal/README.md`. + ## Layout ```text diff --git a/docs/architecture/studio-finger-visual-feedback-plan.md b/docs/architecture/studio-finger-visual-feedback-plan.md new file mode 100644 index 0000000..3cb9a31 --- /dev/null +++ b/docs/architecture/studio-finger-visual-feedback-plan.md @@ -0,0 +1,159 @@ +# Finger 显示一致性与低噪声读数实施方案 + +日期:2026-09-16。检查基线:orisys-app 07d7c3b。本文是实施计划,未修改è¿�行代ç �;未连接实机,未验è¯�目标 ARM 图形驱动。文中新增接å�£å�‡ä¸ºå»ºè®®å¥‘约。 + +## 1. 决策 + +ä¿�ç•™ Studio çš„ QOpenGLWidget/PyOpenGL,移æ¤�旧示例的视觉计算和交互:深度热图ã€�表é�¢å…‰æµ�箭头ã€�黄色接触法å�‘箭头ã€�相机和ç�¯å…‰é¢„设。旧 Open3D 界é�¢ä¿�留作对照工具。先è¯�明å�Œä¸€è¾“入能产生相å�Œçš„æŽ¥è§¦åŒºåŸŸã€�箭头和色标,å†�优化画质。 + +旧效果的主è¦�实现ä¸�在 finger_pipeline_3dview.py å…¥å�£ï¼Œè€Œåœ¨ examples/viz/contact_arrow.pyã€�flow_arrow.pyã€�offscreen_mesh.py,以å�Š src/orisys/finger/mesh.py。几何计算大部分使用 NumPyï¼›Open3D 部分负责网格对象ã€�渲染和窗å�£ã€‚因此无需把整个旧 UI 移入 Studio。 + +如近期必须æ�¢å¤�桌é�¢æ¼”示,å�¯é‡‡ç”¨å�•独å�¯é€‰çš„ Open3D 进程,通过有界最新帧通é�“返回 RGB,Qt 显示图åƒ�并转å�‘相机æ“�作。旧 OffscreenFingerMeshRenderer 已有 RGB 输出接å�£ï¼Œä½†è¿˜è¦�处ç�†è¿›ç¨‹ç”Ÿå‘½å‘¨æœŸã€�帧身份ã€�è¶…æ—¶ã€�å¤�制æˆ�本和平å�°æ”¯æŒ�。这是备选过渡方案,ä¸�作为默认 ARM å�‘布ä¾�èµ–ï¼›ä¸�è¦�直接跨平å�°åµŒå…¥åŽŸç”Ÿçª—å�£å�¥æŸ„。 + +Open3D 官方 ARM 支æŒ�ä»�标为实验性,Linux OpenGL ES çš„ Visualizer/GUI ä¸�支æŒ�。现代渲染路径还è¦�求 OpenGL 4.1;此è¦�求ä¸�å�¯æ— å·®åˆ«å¥—到 legacy Visualizer。Jetson Orin Nanoã€�QCS6490 都è¦�按实际 Ubuntu/BSP/桌é�¢çŽ¯å¢ƒåˆ†åˆ«éªŒè¯�,ä¸�能å�ªä¾�æ�® CPU 架构推断å�¯ç”¨æ€§ã€‚ + +å�‚考:https://www.open3d.org/docs/release/arm.html ,https://www.open3d.org/docs/release/tutorial/visualization/cpu_rendering.html + +## 2. 已确认的差异与待验è¯�事项 + +|问题|代ç �è¯�æ�®|处ç�†| +|---|---|---| +|箭头å“�应而三维ä¸�明显|scene3d/adapters.py çš„ FingerFrameAdapter 默认 depth;二维 arrows.py 使用 flow,Finger 阈值 0.4ã€�é—´è·� 10。旧二维间è·� 20ã€�阈值也为 0.4|ä¸�能认定ç�µæ•�度æ��高æ�¥è‡ªé˜ˆå€¼é™�低;先比较相å�Œæ•°æ�®ã€�网格密度ã€�显示缩放和算法é…�ç½®| +|三维看起æ�¥ä¸�动|当å‰� adapter 明确ä¸�输出 displacementï¼›å�ªåŽŸä½�ç�€è‰²|区分颜色ã€�å…‰æµ�箭头与几何形å�˜ã€‚å…ˆæ�¢å¤�å‰�两者;几何形å�˜å�¦ä»¥â€œç›¸å¯¹å½¢å�˜ç¤ºæ„�â€�实现| +|旧热图对比明显|SDK scalar_map_vertex_colors 对活动顶点采用æ¯�帧 P5/P95 范围|增加探索用稳å�¥è‡ªåŠ¨èŒƒå›´ï¼Œä»¥å�Šæ¯”较用固定范围;始终显示实际范围| +|黄色箭头消失|æ—§ ContactArrowOverlay.compute 使用 centroidã€�局部法å�‘ã€�fnormal;当å‰� SdkPacket 没有接触中心字段|补接触观测契约和渲染å� åР层| +|é�™æ­¢æ•°å­—跳动|Finger sensor.json 默认中值窗å�£ 1ã€�低通 0;旧 CLI 默认曲线低通 5 Hz;当å‰�æ•°å­— raw 一ä½�ã€�N 三ä½�å°�æ•°|先核对è¿�行时覆盖设置,å†�比较滤波;未标定ä¸�是噪声的充分解释| +|纯色背景ä¸�是手指形状|Studio draw_arrow_display 将整个矩形填å�Œè‰²ï¼›æ—§ _arrow_background 用 valid_mask 区分内外颜色|用有效 UV 掩膜绘制轮廓,箭头使用å�Œä¸€å��标域| +|å�¯èƒ½ä¸�å�Œå¸§/过期字段|三维已有字段有效性与 frame_id 检查;AppWindow._render_run_image 构造 ImageFrame 未传入 flow_valid/depth_valid 等信æ�¯|二维补å�Œç­‰æœ‰æ•ˆæ€§ï¼›è®°å½•两视图呈现帧å�·ï¼ŒåŒºåˆ†å»¶è¿Ÿä¸Žé‡�ä¸�å�Œ| + +没有硬件录制输入,尚ä¸�能确定“深度无å“�应â€�是深度算法输出å°�ã€�色标压缩ã€�场失效或呈现延迟。检查 process_chain:Studio 使用 check_motion=False;旧 pipeline 使用 check_motion=True, threshold=0。对照实验è¦�固定这类å�‚数,ä¸�能把算法差异归因于渲染库。 + +## 3. æ•°æ�®æµ�与模å�—边界 + +```text +SDK æ¯�帧计算 + → SdkPacket(测é‡�值ã€�UV 域ã€�字段有效性ã€�接触中心ã€�帧身份) + ├─ 现有完整记录通é�“ + ├─ 现有全程力历å�² + └─ Finger 展示构建器(有界最新帧通é�“) + → å�Œå¸§ VisualBundle + ├─ 二维背景/箭头 + ├─ 三维标é‡�/箭头几何 → GL + └─ 接触状æ€�与诊断 + +力历å�² → ForcePresentation → 曲线显示副本/æ•°å­—å�¡ +``` + +测é‡�ã€�展示政策ã€�渲染å�Žç«¯å�•å�‘ä¾�赖。Qt/GL ä¸�进入 SDKï¼›SDK ä¸�导入 examplesï¼›shared ä¸�导入 Studio。旧 NumPy 几何函数æ��å�–到正å¼�模å�—并让旧示例å¤�用,ç¦�æ­¢ Studio 通过 sys.path 导入 examples。 + +先扩展现有 contractsã€�adaptersã€�composer,ä¸�é‡�建第二套设备/会è¯�/回放系统。新增模å�—按责任å�ˆå¹¶ä¸º visual_policy.pyã€�overlays.pyã€�force/presentation.pyï¼›å�ªæœ‰å®žæ—¶å�Œæ­¥éœ€è¦�æ—¶å†�增加 visual_presenter.py。文件数ä¸�是主è¦�指标,修改阈值ä¸�应è¦�求å�Œæ—¶ä¿®æ”¹ workerã€�GL shader å’Œ app_window。 + +### 帧和有效性 + +å¤�用 FrameIdentity,传播 source/session/generation/frame/time。新增字段接入已有数æ�®ç±»æ—¶ä½¿ç”¨å�¯é€‰å€¼å’Œå…¼å®¹é»˜è®¤å€¼ï¼›æ—§å½•制缺少接触信æ�¯æ—¶ä¸�伪造黄色箭头。 + +- `ContactObservation`: `centroid_uv_xy: tuple[float,float] | None`ã€�`found: bool`ã€�`valid: bool`ã€�`frame_id: int`ã€�`domain_id: str`。中心å�•ä½�是 UV åƒ�素,顺åº� x,y。 +- `VisualBundle`: identityã€�image_frameã€�surface_frameã€�contactã€�overlay_instancesã€�policy_revision。æ¯�个数组须是ä¸�å�¯å�˜å¿«ç…§æˆ–有明确所有æ�ƒï¼›ä¸�能引用 SDK 下一帧会覆写的缓冲。 +- `ArrowInstances`: origins[M,3]ã€�directions[M,3]ã€�lengths[M]ã€�colors[M,4]ã€�kind。ä½�置与长度在 scene 的统一å��æ ‡å�•ä½�下,方å�‘归一化,数组有é™�且 M 有上é™�。 +- 字段无效/过期å�ªéš�è—�对应层;有深度无力时ä»�å�¯æ˜¾ç¤ºæ·±åº¦ï¼Œæ²¡æœ‰æœ‰æ•ˆ FN æ—¶éš�è—�法å�‘强弱箭头。无效ä¸�等于 0。 + +默认实时ä¿�æŒ�低延迟,ä¸�让三维阻塞采集/记录。增加“å�Œæ­¥æ¯”较â€�模å¼�:å�Œä¸€ bundle 的二维和三维å�‡å‡†å¤‡å®Œæˆ�å�Žï¼Œåœ¨å�Œä¸€ä¸ª GUI 更新周期æ��交;ä¿�留至多一个工作中任务和一个最新等待帧,整体跳过中间展示帧。曲线ä¿�存全é‡�,比较游标和数字å�– bundle 对应时间。ä¸�能宣称ä¸�å�Œ QWidget 在物ç�†æ˜¾ç¤ºæ‰«æ��上严格原å­�å�Œæ­¥ã€‚ + +## 4. 阈值与空间一致性 + +ä¸�è¦�设置一个数值å�Œæ—¶ä½œç”¨äºŽ flow(px)ã€�depth_response(相对值)ã€�force(raw/N)。统一的是é…�置入å�£ã€�帧身份ã€�空间域和视觉规则。 + +`VisualPolicy` å�« flow_on_px/flow_off_pxã€�depth_on/depth_offã€�色标策略ã€�箭头网格间è·�ã€�长度上é™�ã€�接触箭头样å¼�。on > off æž„æˆ�迟滞。数值由无载样本å�Šè½»è§¦æ£€å‡ºå®žéªŒç¡®å®šï¼Œä¸�ç…§æ�¬ä¸�å�Œè®¾å¤‡çš„å�‚数。 + +三维光æµ�箭头与二维光æµ�箭头共享å�Œä¸€ flow 阈值和采样点。深度色彩ä¿�留自己的有效区å�Šé˜ˆå€¼ï¼›è½»å¾®åˆ‡å�‘滑动å�¯èƒ½å�ªæœ‰å…‰æµ�å“�应,ä¸�应为了“å�Œæ­¥â€�伪造深度。也ä¸�应以深度掩膜è£�掉所有切å�‘å“�应。 + +几何有效区ã€�纹ç�†æœ‰æ•ˆåŒºã€�接触区分开: + +- geometry_mask:UV 映射有效且对应真实传感表é�¢ã€‚ +- texture_roi:确实需è¦�排除无纹ç�†è¾¹ç¼˜æ—¶ï¼Œç¦»çº¿æŒ‡å®š/人工确认并éš�几何资产版本ä¿�存。 +- display_mask = geometry_mask ∩ texture_roi(无 texture_roi æ—¶ä»… geometry_mask)。 +- contact_mask:éš�帧å�˜åŒ–,å�ªå†³å®šæŽ¥è§¦ç›¸å…³å� åŠ ï¼Œä¸�改å�˜é�™æ€�手指轮廓。 + +ç¦�æ­¢æ¯�帧通过é�žé»‘é�žç™½ RGB 判定有效区;光照ã€�黑色标记ã€�å��光会改å�˜å®ƒã€‚掩膜形状或 domain ä¸�匹é…�应报错,ä¸�通过任æ„� resize 掩盖问题。绘制纯色背景时分别设置内外颜色;箭头端点也è£�到有效区。UV 翻转ã€�è£�剪ã€�缩放å�ªåœ¨æ˜Žç¡®çš„域转æ�¢å¤„å�šä¸€æ¬¡ï¼Œå� åŠ æ˜¾ç¤ºè¾¹ç•Œä»¥ä¾›éªŒæ”¶ã€‚ + +## 5. 三维效果æ�¢å¤� + +1. 表é�¢çƒ­å›¾ï¼šæŽ¢ç´¢æ¨¡å¼�以有效活动值的稳å�¥åˆ†ä½�范围增强对比,设置最低色标跨度,缓慢更新范围;空场ä¿�æŒ�基底色。对比模å¼�é”�定范围。ä¸�è¦�默认用æ¯�帧æž�å°�噪声铺满色谱。 +2. 表é�¢å…‰æµ�箭头:å¤�用旧 flow_arrow 的采样与映射语义;åˆ�版ä¿�æŒ�相å�Œç‚¹é›†å’Œå°ºåº¦ã€‚è‹¥å�¦æ”¹æˆ�切å�‘映射,须å�•独验收。原点加å°�é‡�法å�‘å��ç§»é�¿å…�é—ªçƒ�。 +3. 黄色法å�‘箭头:æ�¢å¤�旧接触中心å�Šå±€éƒ¨æ³•å�‘,长度按 FN 的对数/幂律显示映射并é™�幅;注明“法å�‘å“�应示æ„�â€�,ä¸�声称长度线性对应牛顿。中心è�½åœ¨æ— æ•ˆåŒºæ—¶éš�è—�,ä¸�æ— æ�¡ä»¶å�¸é™„到远处顶点。 +4. 三轴å�ˆåŠ›ç®­å¤´æ˜¯å�Žç»­ç‹¬ç«‹åŠŸèƒ½ï¼šå�ªæœ‰ç»Ÿä¸€è½´å®šä¹‰ã€�标定比例ã€�力的å�‚考å��标系å�Šåˆ° scene 的旋转确认å�Žæ‰�生æˆ�。ä¸�能直接把未标定 FN/FX/FY 当æˆ�空间三维å�‘é‡�。 +5. 外壳ä¿�æŒ�é�™æ€�中性色,å�ªç»™ä¼ æ„Ÿè¡¨é�¢ç�€è‰²ï¼›å��标轴ã€�相机ã€�光照预设å�¯å¤�用旧版设计。 + +GL åˆ�版用缓存圆柱/圆锥模æ�¿å’Œæ¯�帧实例å�‚数;也å�¯ç”¨æœ‰é™�æ•°é‡�箭头å�ˆå¹¶ç½‘格,先测性能å†�引入 instancing。ä¸�è¦�æ¯�帧新建数百个 Python/OpenGL 网格对象。éš�è—�三维时å�œç»„å�ˆå’Œä¸Šä¼ ã€‚ + +## 6. 数值稳定ã€�零漂与曲线 + +先记录实际生效的 SDK FilterSettings。滤波å�ªç”±ä¸€ä¸ªå±‚负责,沿用现有 worker 下å�‘ SDK 的路径;shared ForceFilterChain 当å‰�是对照实现,ä¸�应å�ˆåœ¨ UI 加一é��。 + +å�¯ç”¨æ—§ç‰ˆ 5 Hz 作为 A/B 起点,而é�žå›ºå®šéªŒæ”¶å�‚数;截止频率必须低于实际采样率的一å�Šï¼Œå¹¶æµ‹è¯•短促接触峰值和延迟。中值窗å�£å¤§äºŽ 1 仅在确认脉冲异常时å�¯ç”¨ã€‚ + +区分四件事: + +- 滤波:å‡�少高频噪声,存在延迟。 +- 无载归零:在用户确认无载ã€�采样有效且稳定的窗å�£ä¼°è®¡å��置,冻结到本次会è¯�。慢漂ä¸�能é� å›ºå®šæ­»åŒºæ— é™�修正;ä¸�è¦�在æŒ�ç»­é�™è½½æ—¶è‡ªåŠ¨è¿½é›¶ã€‚ +- 显示死区:å�ªä½œç”¨äºŽå±•示副本;正负对称ã€�带迟滞,ä¸�截掉负 FN。raw/N å�„自ä¿�存阈值和å��置版本。切æ�¢å�•ä½�/标定/设备/滤波时é‡�置或é‡�新验è¯�政策。 +- æ•°å­—æ ¼å¼�:优先按噪声分辨率é‡�化和稳定的å°�æ•°ä½�ï¼›raw å�¯èµ·æ­¥ 3–4 ä½�有效数字或整数步长,N ä¾�标定分辨率确定。固定宽度等宽数字,数字å�¡å�¯é™�到 5–10 Hz,曲线ä»�用完整时间åº�列。处ç�†è´Ÿé›¶å�Š NaN。 + +死区阈值å�¯ä»Žæ— è½½æ®‹å·®çš„ MAD/分ä½�数估计,必须给出噪声窗å�£ã€�å�•ä½�和适用é…�置。展示 0 时显示“零区â€�标识,å�¯æŸ¥çœ‹æœªæŠ‘制值。曲线默认显示滤波å�Žçš„连续信å�·å’Œæ·¡è‰²é›¶åŒºå¸¦ï¼›â€œé›¶åŒºåދ平â€�是明确开关,ä¸�建议默认把真实弱信å�·åŽ‹æ²¡ã€‚ + +选区统计和导出使用测é‡�åº�列,ä¸�能统计数字å�¡é‡�化值;如支æŒ�展示信å�·ç»Ÿè®¡ï¼Œå¿…é¡»å�¦è¡Œå‘½å��。核实 SDK raw 通é�“到底是滤波å‰�还是仅标定å‰�,ä¸�把现有 raw 列宣传æˆ�未滤波原始é‡�。所有新增展示政策ä¸�回写现有记录值;政策/å½’é›¶å�˜æ›´å†™å¸¦æ—¶é—´çš„事件和版本以便回放解释。 + +## 7. 文件å�Šå‡½æ•°ä¿®æ”¹æ¸…å�• + +以下路径以仓库根为起点。existing 表示改现有函数,new 表示建议新增接å�£ï¼›è¯¦ç»†è¾“å…¥/输出规则è§�上文。 + +|路径|接å�£|è�Œè´£å�Šè¾“入输出| +|---|---|---| +|orisys-app/shared/src/orisys_app_shared/acquisition/models.py|SdkPacket(existing)|增加å�¯é€‰ ContactObservation;帧字段与 UV 域一致| +|å�Œç›®å½• finger_worker.py|采集循环构造 SdkPacket 处(existing)|在 compute_contact å�Žè¯»ä¸­å¿ƒå�Š found;读失败标记ä¸�å�¯ç”¨ï¼Œå¤�制å�Žå�‘布,ä¸�能由 UI é‡�新计算 SDK| +|å�Œç›®å½• image_backend.py|ImageFrameã€�plan_imageã€�render_image(existing)|æ�ºå¸¦å�„字段有效性与身份;失效箭头/深度显示ä¸�å�¯ç”¨| +|å�Œç›®å½• arrows.py|draw_arrow_display(existing)|增加 policy/内外色å�‚数;背景ã€�采样点和箭头è£�剪共用 domain_mask| +|orisys-app/shared/src/orisys_app_shared/scene3d/visual_policy.py(new)|VisualPolicy; build_domain_mask(geometry_mask, texture_roi) -> bool[H,W]; select_flow_samples(flow, mask, policy) -> UVSamples|æ—  Qt/GL;严格检查形状ã€�有é™�值和域;采样点两视图共用| +|å�Œç›®å½• overlays.py(new)|build_flow_overlay(samples, surface_mapping, style) -> ArrowInstances; build_normal_overlay(contact, fn, surface_mapping, style) -> ArrowInstances|å�ªå�š NumPy 几何;无接触/无有效力返回零实例,é�žæ³•å��标返回诊断| +|å�Œç›®å½• contracts.py|SurfaceFieldFrameã€�RenderFrame(existing)|扩展å�¯é€‰å�‘é‡�/接触/箭头å�Šæ��è¿°ä¿¡æ�¯ï¼Œä¿�æŒ�æ—  overlay 的旧调用兼容| +|å�Œç›®å½• composer.py|SceneComposer.compose(existing)|构建å�Œå¸§è¡¨é�¢å’Œå� åŠ ï¼›ç¼“å­˜é�™æ€�映射/法å�‘/模æ�¿ï¼Œä¸�读 SDK| +|orisys-app/apps/studio/src/orisys_studio/scene3d/adapters.py|FingerFrameAdapter.snapshot(existing)|å�Œæ—¶éªŒè¯�需è¦�çš„ flow/depth/contact 身份;选择上色场ä¸�应删除箭头所需 flowï¼›ä¿�留显å¼�é™�级æ��示| +|å�Œç›®å½• gl_backend.py|现有上传/绘制/资æº�释放路径(existing)|上传 ArrowInstances 或å�ˆå¹¶ç½‘格;å�ªæœ‰æ­¤å±‚ä¾�èµ– GLï¼›éš�上下文é‡�建资æº�| +|å�Œç›®å½• view.pyã€�coordinator.py|submit_packetã€�tick(existing)|æ�ºå¸¦ bundle 身份与策略版本;过期结果ä¸�覆盖新设置,æ��供已呈现帧å�·| +|å�Œç›®å½• panel.py|ThreeDPanel(existing)|上色é‡�ã€�å…‰æµ�箭头ã€�接触法å�‘箭头ã€�固定/自动色标;诊断折å� | +|orisys-app/shared/src/orisys_app_shared/force/presentation.py(new)|ForcePresentation.configure(policy) -> None; accept(sample) -> DisplayForce; fit_zero(samples, context) -> ZeroEstimate; format_force(value, resolution, unit) -> str|独立展示状æ€�ï¼›fit_zero ä¸�éš�å¼�应用,返回å��ç½®/噪声/样本数/è´¨é‡�ï¼›accept ä¸�修改 sample| +|orisys-app/apps/studio/src/orisys_studio/ui/force_plots.py|format_forceã€�_render_value_cards å�Šæ›²çº¿å±•示入å�£ï¼ˆexisting)|统一调用展示模型;删除两处é‡�å¤�æ ¼å¼�规则;历å�²/导出ä¸�é‡�化;选区统计明确å�£å¾„| +|orisys-app/apps/studio/src/orisys_studio/app_window.py|_render_run_imageã€�_show_image_frame(existing)|仅路由字段和政策,ä¸�加入阈值ã€�滤波ã€�中心计算算法| +|orisys-sdk/examples/viz/contact_arrow.pyã€�flow_arrow.py|compute/几何构建函数(reference)|作为金标准验è¯�ç§»æ¤�ï¼›ä¸�å¤�制 Open3D 生命周期到 Studio| + +`ForcePresentation` 状æ€�按设备/SNã€�sessionã€�å�•ä½�ã€�calibration revisionã€�filter revision 分开。历å�²éš�机 seek ä¸�得从上一 seek 的迟滞状æ€�继续:从记录的政策边界和足够å‰�导样本é‡�放,或选择无状æ€�死区并明确模å¼�。事件ä¸�能混入常规力 CSV 数值列。 + +## 8. 顺åº�与验收 + +### A:先建立å�¯æ¯”较输入,修正误导 + +用å�Œä¸€åŽŸè§†é¢‘ã€�é…�ç½®ã€�åˆ�始化/预热步骤ã€�SDK 版本分别驱动旧/新界é�¢ï¼›æœ€å¥½æ�•获一次中间场 bundle å†�供两套渲染器消费,排除算法与滤波差异。记录 flow/depth 范围ã€�有效比例ã€�中心ã€�原始/输出力ã€�实际滤波å�‚æ•°ã€�帧å�·å’Œå‘ˆçŽ°æ—¶é—´ã€‚ + +先交付 UV 轮廓ã€�二维有效性ã€�实际色标ã€�集中数字格å¼�å�Šå™ªå£°è¯Šæ–­ã€‚这一阶段ä¸�æ�¢æ¸²æŸ“器,也ä¸�引入全局阈值。 + +### B:æ�¢å¤�旧版关键视觉 + +交付å�Œæº� 2D/3D å…‰æµ�箭头ã€�黄色法å�‘箭头ã€�相机/ç�¯å…‰ï¼›å…ˆ CPU 几何对比,å†�检查 GL 图åƒ�。UI 将三维画布作为主体,设置折å� ï¼›ä¸‰ç»´ä¸Šè‰²é»˜è®¤æ·±åº¦å“�应,å� åŠ å…‰æµ�æ–¹å�‘以解释切å�‘滑动。æ��供二维/三维切æ�¢å’Œå¹¶æŽ’诊断。 + +### C:显示抑制与å�Œæ­¥æ¯”较 + +交付无载评估ã€�显示死区ã€�零区带ã€�æ•°å­—å�¡åˆ·æ–°çއã€�å�Œæ­¥æ¯”较模å¼�å�Šå¯¹åº”政策事件。先按实验数æ�®é€‰æ‹©é˜ˆå€¼ï¼Œå†�考虑产å“�预设“ç�µæ•�/稳定â€�ï¼›ä¿�留明确的å�‚数查看入å�£ã€‚ + +### D:目标æ�¿éªŒè¯� + +分别测试 Jetson Orin Nanoã€�QCS6490 实际 GL/ES 上下文ã€�shaderã€�DPIã€�窗å�£éš�è—�/æ�¢å¤�ã€�资产é‡�è½½ã€�长时间显存和内存ã€�关闭中断ã€�30 fps 预算。CPU 组å�ˆä¸Ž GPU 上传/绘制分别计时,记录 p50/p95 延迟和展示跳帧;硬件实测å�Žå†�承诺帧率。 + +å…±å�ŒéªŒæ”¶ç”¨ä¾‹ï¼šæ— è½½ã€�轻滑ã€�法å�‘压入ã€�æŒ�ç»­é�™è½½ã€�释放ã€�边缘接触ã€�无效帧ã€�断连ã€�切æ�¢ raw/Nã€�回放 seek。 + +- å�Œå¸§è¾“入的二维/三维采样点ã€�接触中心ã€�掩膜边界符å�ˆå‡ ä½•映射,黄色箭头局部法å�‘正确。 +- 测é‡�记录在开关显示死区/数字精度å‰�å�Žé€�值一致;异常ä»�为无效,负值ä»�å�¯æ£€æŸ¥ã€‚ +- 稳定模å¼�å‡�å°‘é�™æ­¢è¯»æ•°å�˜åŒ–,但轻触检出ã€�峰值å��å·®ã€�端到端延迟符å�ˆå›¢é˜Ÿäº‹å…ˆçº¦å®šæŒ‡æ ‡ï¼›ä¸�能å�ªç”¨â€œçœ‹èµ·æ�¥ä¸�è·³â€�验收。 +- 色标自动模å¼�å�¯è§�实际上下é™�;固定模å¼�相å�Œæ•°å€¼ä¿�æŒ�å�Œè‰²ï¼›ç©ºåœºä¸�被放大æˆ�强接触。 +- å�Œæ­¥æ¯”较 mode çš„ frame_id 一致;常规实时 mode 的延迟å�¯è§�且队列有界。 + +工作é‡�按交付拆分评估:背景与数字格å¼�较å°�;接触字段贯通å�Šç®­å¤´å‡ ä½•为中等;å�Œå¸§æ¯”较ã€�回放状æ€�å’Œ ARM 实测为主è¦�ä¸�确定项。先完æˆ� B çš„å�•段录制验è¯�,å†�估算整体工期。直接加入 Open3D 也需è¦�å�Žä¸€ç»„工作,并ä¸�自动çœ�掉它们。 diff --git a/pyproject.toml b/pyproject.toml index d11dbdc..e85dfd0 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 (studio + future internal)" +description = "Orisys desktop applications (studio + internal autocal)" requires-python = ">=3.10" readme = "README.md" authors = [{ name = "Orisys-Tech" }] @@ -14,6 +14,7 @@ authors = [{ name = "Orisys-Tech" }] dev = [ "orisys-app-shared @ file:./shared", "orisys-studio @ file:./apps/studio", + "orisys-autocal @ file:./apps/autocal", "pytest>=7.4", ] @@ -21,6 +22,6 @@ dev = [ packages = [] [tool.pytest.ini_options] -testpaths = ["shared/tests", "apps/studio/tests"] -pythonpath = ["shared/src", "apps/studio/src"] +testpaths = ["shared/tests", "apps/studio/tests", "apps/autocal/tests"] +pythonpath = ["shared/src", "apps/studio/src", "apps/autocal/src"] addopts = "-q" diff --git a/shared/resources/zmotion/x64/README.md b/shared/resources/zmotion/x64/README.md new file mode 100644 index 0000000..92ae2a4 --- /dev/null +++ b/shared/resources/zmotion/x64/README.md @@ -0,0 +1,42 @@ +# Vendored ZMotion runtime (internal use) + +These files are copied verbatim from the ZMotion SDK that ships with the +controller's `ZDevelop` install, as unpacked from the vendor example bundle: + + D:\orisys_ws\zmotion\Python64\dll库文件\ + +| File | Bytes | SHA-256 (first 16) | +|---|---|---| +| `zauxdll.dll` | 4410880 | `282369258733A0D6` | +| `zmotion.dll` | 3596288 | `30DC519539783EE9` | +| `zauxdll2.h` | 179106 | `BC8788DE71A7900B` | + +`zauxdll2.h` is kept only as the parameter contract of record for the wrappers +in `orisys_app_shared/zmotion/client.py` (register values, argument order, +return codes). It is not loaded at run time. + +## Why they are vendored + +`zmcdll/zauxdllPython.py` in the vendor example resolves its DLLs relative to +its own module location, plus a current-working-directory fallback. That makes +`import` depend on where the process happens to be started from. Copying the +two runtime DLLs next to the app and resolving them explicitly (see +`orisys_app_shared/zmotion/loader.py`) removes that, at the cost of ~8 MB in +the repo. + +## Overriding + +Set `ORISYS_ZMOTION_DLL_DIR` to point at a different directory — for example a +newer controller-support CD — and it wins over this folder: + +```powershell +$env:ORISYS_ZMOTION_DLL_DIR = "D:\Zmotion\bin" +``` + +## Redistribution + +Internal use only. The ZMotion SDK is vendor-supplied hardware support +software with its own terms; these DLLs must **not** be redistributed inside +the customer-facing Orisys Studio installer. AutoCal is a separate, internal +application and is deliberately not packaged by `build/studio` (see +`build/README.md`). diff --git a/shared/resources/zmotion/x64/zauxdll.dll b/shared/resources/zmotion/x64/zauxdll.dll new file mode 100644 index 0000000..9f58b76 Binary files /dev/null and b/shared/resources/zmotion/x64/zauxdll.dll differ diff --git a/shared/resources/zmotion/x64/zauxdll2.h b/shared/resources/zmotion/x64/zauxdll2.h new file mode 100644 index 0000000..9f7967c --- /dev/null +++ b/shared/resources/zmotion/x64/zauxdll2.h @@ -0,0 +1,4279 @@ +/********************************** ZMCϵÁпØÖÆÆ÷ ************************************************ +**--------------ÎļþÐÅÏ¢-------------------------------------------------------------------------------- +**ÎļþÃû: zmcaux.h +**´´½¨ÈË: ֣ТÑó +**ʱ¼ä: 20130621 +**ÃèÊö: ZMCDLL ¸¨Öúº¯Êý + +**------------ÐÞ¶©ÀúÊ·¼Ç¼---------------------------------------------------------------------------- + +** ÐÞ¸ÄÈË: zxy +** °æ ±¾: 1.1 +** ÈÕ¡¡ÆÚ: 2014.5.11 +** Ãè¡¡Êö: ZMC_ExecuteNoAck Ìæ»»Îª ZMC_Execute + + +** ÐÞ¸ÄÈË: zxy +** °æ ±¾: 1.3 +** ÈÕ¡¡ÆÚ: 2014.7.21 +** Ãè¡¡Êö: ZMC_Execute ZMC_DirectCommand Ìæ»»ÎªZAux_Execute ZAux_DirectCommand + +Ôö¼Ó ZAux_SetParam ZAux_GetParam ZAux_Direct_SetParam ZAux_Direct_GetParam + +Ôö¼Ó ZAux_WriteUFile ZAux_ReadUFile + +** ÐÞ¸ÄÈË: wy +** °æ ±¾: 1.5 +** ÈÕ¡¡ÆÚ: 2016.6.6 +** Ãè¡¡Êö: ¶ÔËùÓÐBASICÖ¸Áî½øÐзâ×°£¬ÕûºÏZMC¿âµ½AUX¿â + + + ** ÐÞ¸ÄÈË: wy +** °æ ±¾: 2.1 +** ÈÕ¡¡ÆÚ: 2018.8.24 +** Ãè Êö£ºÌí¼ÓPCIÁ´½Óº¯Êý +** ¶ÔËùÓÐBASICÖ¸ÁîÔ˶¯Ö¸Áî½øÐзâ×°£¬·â×°ÖáÁÐ±íµ½º¯Êý +** Ôö¼Ó²¿·Ö×ÜÏßÖ¸Áî +** Ôö¼Ó²¿·ÖMOVE_PARAÖ¸Áî + Ôö¼ÓλÖñȽÏÊä³öÖ¸Áî +**------------------------------------------------------------------------------------------------------ +********************************************************************************************************/ + + + + +#ifndef _ZMOTION_DLL_AUX_H +#define _ZMOTION_DLL_AUX_H + + +//ZAUXÖ§³ÖµÄ×î´óÖáÊýºê +#define MAX_AXIS_AUX 128 +#define MAX_CARD_AUX 32 + +#ifdef __cplusplus +extern "C" { +#endif + + + + + +/********************************************************* +Êý¾ÝÀàÐͶ¨Òå +**********************************************************/ + +typedef unsigned __int64 uint64; +typedef __int64 int64; + + +//#define BYTE INT8U +//#define WORD INT16U +//#define DWORD INT32U +typedef unsigned char BYTE; +typedef unsigned short WORD; +//typedef unsigned int DWORD; +//#define __stdcall +typedef unsigned char uint8; /* defined for unsigned 8-bits integer variable ÎÞ·ûºÅ8λÕûÐͱäÁ¿ */ +typedef signed char int8; /* defined for signed 8-bits integer variable ÓзûºÅ8λÕûÐͱäÁ¿ */ +typedef unsigned short uint16; /* defined for unsigned 16-bits integer variable ÎÞ·ûºÅ16λÕûÐͱäÁ¿ */ +typedef signed short int16; /* defined for signed 16-bits integer variable ÓзûºÅ16λÕûÐͱäÁ¿ */ +typedef unsigned int uint32; /* defined for unsigned 32-bits integer variable ÎÞ·ûºÅ32λÕûÐͱäÁ¿ */ +typedef signed int int32; /* defined for signed 32-bits integer variable ÓзûºÅ32λÕûÐͱäÁ¿ */ +typedef float fp32; /* single precision floating point variable (32bits) µ¥¾«¶È¸¡µãÊý£¨32볤¶È£© */ +typedef double fp64; /* double precision floating point variable (64bits) Ë«¾«¶È¸¡µãÊý£¨64볤¶È£© */ +typedef unsigned int uint; /* defined for unsigned 32-bits integer variable ÎÞ·ûºÅ32λÕûÐͱäÁ¿ */ + +typedef void* ZMC_HANDLE; + + +/************************************************/ +//´íÎóÂë +/************************************************/ +#define ERR_OK 0 +#define ERROR_OK 0 +#define ERR_SUCCESS 0 + +#define ERR_AUX_OFFSET 30000 + +#define ERR_NOACK ERR_AUX_OFFSET //ÎÞÓ¦´ð +#define ERR_ACKERROR (ERR_AUX_OFFSET+1) //Ó¦´ð´íÎó +#define ERR_AUX_PARAERR (ERR_AUX_OFFSET+2) //²ÎÊý´íÎó +#define ERR_AUX_NOTSUPPORT (ERR_AUX_OFFSET+3) //²»Ö§³Ö + +#define ERR_AUX_FILE_ERROR (ERR_AUX_OFFSET+4) //²ÎÊý´íÎó +#define ERR_AUX_OS_ERR (ERR_AUX_OFFSET+5) // + +#if 0 + //´íÎóÊä³ö²¿·Ö +#endif + + +//#define ZAUX_DEBUG //²»ÐèÒªÊä³öʱעÊ͵ôÕâÐÐ +//#define ZAUX_DEBUG_FILE "d:\\zauxout.txt" + +#define ZAUX_COMMAND_SUM 100 //дÃüÁîÊý£¬µ÷ÊÔʱʹÓà + +#ifdef ZAUX_DEBUG + +#define ZAUX_TRACE(cText,a) ZAux_TraceOut("%s,L:%u,"cText"\r\n",__FILE__,(unsigned int)__LINE__,a) +#define ZAUX_WARN(cText,a) ZAux_TraceOut("%s,L:%u,"cText"\r\n",__FILE__,(unsigned int)__LINE__,a) +#define ZAUX_ERROR(cText,a) ZAux_TraceOut("Error:%s,L:%u,"cText"\r\n",__FILE__,(unsigned int)__LINE__,a) +#define ZAUX_ERROR2(cText,a,b) ZAux_TraceOut("Error:%s,L:%u,"cText"\r\n",__FILE__,(unsigned int)__LINE__,a,b) + +#else + +#define ZAUX_TRACE(cText,a) +#define ZAUX_WARN(cText,a) +#define ZAUX_ERROR(cText,a) +#define ZAUX_ERROR2(cText,a,b) + +#endif + +void ZAux_TraceOut(const char * cText, ...); + + + +/********************************************************* +º¯ÊýÉùÃ÷ +**********************************************************/ + + +/************************************************************* +Description: //·â×° Excute º¯Êý, ÒÔ±ã½ÓÊÕ´íÎó +Input: //¿¨Á´½Ó handle +Input: //×Ö·û´®ÃüÁî pszCommand +Input: //·µ»ØµÄ×Ö·û³¤¶È uiResponseLength +Output: //·µ»ØµÄ×Ö·û´® psResponse +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Execute(ZMC_HANDLE handle, const char* pszCommand, char* psResponse, uint32 uiResponseLength); + + +/************************************************************* +Description: //·â×° DirectCommand º¯Êý, ÒÔ±ã½ÓÊÕ´íÎó +Input: //¿¨Á´½Ó handle +Input: //×Ö·û´®ÃüÁî pszCommand +Input: //·µ»ØµÄ×Ö·û³¤¶È uiResponseLength +Output: //·µ»ØµÄ×Ö·û´® psResponse +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_DirectCommand(ZMC_HANDLE handle, const char* pszCommand, char* psResponse, uint32 uiResponseLength); + +/************************************************************* +Description: //ÃüÁî¸ú×ÙÉèÖÃ. +Input: //¿¨Á´½Óhandle +bifTofile 0 ¹Ø±Õ 1-Ö»Êä³ö´íÎóÃüÁî 2-Ö»Êä³öÔ˶¯ÓëÉèÖÃÃüÁî 3Êä³öÈ«²¿ÃüÁî +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_SetTraceFile(int bifTofile, const char *pFilePathName); + +/************************************************************* +Description: //¶ÁÈ¡ÃüÁî¸ú×ÙÉèÖÃ. +Input: //¿¨Á´½Óhandle +bifTofile 0 ¹Ø±Õ 1-Ö»Êä³ö´íÎóÃüÁî 2-Ö»Êä³öÔ˶¯ÓëÉèÖÃÃüÁî 3Êä³öÈ«²¿ÃüÁî +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_GetTraceFile(int *bifTofile); + + +/************************************************************* +Description: //Óë¿ØÖÆÆ÷½¨Á¢Á´½Ó£¬ ´®¿Ú·½Ê½. +Input: //´®¿ÚºÅCOMId +Output: //¿¨Á´½Óphandle +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_OpenCom(uint32 comid, ZMC_HANDLE * phandle); + +/************************************************************* +Description: //¿ìËÙ¿ØÖÆÆ÷½¨Á¢Á´½Ó +Input: //×îС´®¿ÚºÅuimincomidfind +Input: //×î´ó´®¿ÚºÅuimaxcomidfind +Input: //Á´½Óʱ¼äuims +Output: //ÓÐЧCOM pcomid +Output: //¿¨Á´½Óhandle +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_SearchAndOpenCom(uint32 uimincomidfind, uint32 uimaxcomidfind,uint* pcomid, uint32 uims, ZMC_HANDLE * phandle); + +/************************************************************* +Description: //¿ÉÒÔÐÞ¸ÄȱʡµÄ²¨ÌØÂʵÈÉèÖà +Input: //dwBaudRate ²¨ÌØÂÊ + dwByteSize Êý¾Ýλ + dwParity = NOPARITY,УÑéλ + dwStopBits = ONESTOPBITֹͣλ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_SetComDefaultBaud(uint32 dwBaudRate, uint32 dwByteSize, uint32 dwParity, uint32 dwStopBits); + +/************************************************************* +Description: //Ð޸ĿØÖÆÆ÷IPµØÖ· +Input: //¿¨Á´½Óhandle +Input: //IPµØÖ· +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_SetIp(ZMC_HANDLE handle, char * ipaddress); + +/************************************************************* +Description: //Óë¿ØÖÆÆ÷½¨Á¢Á´½Ó +Input: //IPµØÖ·£¬×Ö·û´®µÄ·½Ê½ÊäÈë +Output: //¿¨Á´½Óhandle +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_OpenEth(char *ipaddr, ZMC_HANDLE * phandle); + + +/************************************************************* +Description: //¿ìËÙ¼ìË÷IPÁбí +Input: //uims ÏìӦʱ¼ä +Input: //addrbufflength ×î´ó³¤¶È +output: //ipaddrlist µ±Ç°ÍíµãIPÁбí +Return: //´íÎóÂë, ERR_OK±íʾÓÐËÑË÷µ½. +*************************************************************/ +int32 __stdcall ZAux_SearchEthlist(char *ipaddrlist, uint32 addrbufflength, uint32 uims); + + +/************************************************************* +Description: //¿ìËÙ¼ìË÷¿ØÖÆÆ÷ +Input: //¿ØÖÆÆ÷IPµØÖ· +Input: //ÏìӦʱ¼ä +Output: // +Return: //´íÎóÂë, ERR_OK±íʾÓÐËÑË÷µ½. +*************************************************************/ +int32 __stdcall ZAux_SearchEth(const char *ipaddress, uint32 uims); + +/************************************************************* +Description: //¹Ø±Õ¿ØÖÆÆ÷Á´½Ó +Input: //¿¨Á´½Óhandle +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Close(ZMC_HANDLE handle); + +/************************************************************* +Description: //ÔÝÍ£¼ÌÐøÔËÐÐBASÏîÄ¿ +Input: //¿¨Á´½Óhandle +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Resume(ZMC_HANDLE handle); + +/************************************************************* +Description: //ÔÝÍ£¿ØÖÆÆ÷ÖÐBAS³ÌÐò +Input: //¿¨Á´½Óhandle +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Pause(ZMC_HANDLE handle); + +/************************************************************* +Description: //µ¥¸öBASÎļþÉú³ÉZAR²¢ÇÒÏÂÔØµ½¿ØÖÆÆ÷ÔËÐÐ +Input: //¿¨Á´½Óhandle +Input: //Filename BASÎļþ·¾¶ +Input: //RAM-ROM 0-RAM 1-ROM +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BasDown(ZMC_HANDLE handle,const char *Filename,uint32 run_mode); + + + + +#if 0 + //IOÖ¸Áî + // ¿ÉÒÔʹÓà ZMC_GetIn ZMC_GetOutput µÈ +#endif +/************************************************************* +Description: //¶ÁÈ¡ÊäÈëÐźŠ+Input: //¿¨Á´½Óhandle + ionum IN±àºÅ +Output: //piValue ÊäÈë¿Ú״̬ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetIn(ZMC_HANDLE handle, int ionum , uint32 *piValue); + + +/************************************************************* +Description: //´ò¿ªÊä³öÐźŠ+Input: //¿¨Á´½Óhandle + ionum Êä³ö¿Ú±àºÅ + iValue Êä³ö¿Ú״̬ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetOp(ZMC_HANDLE handle, int ionum, uint32 iValue); + +/************************************************************* +Description: //¶ÁÈ¡Êä³ö¿Ú״̬ +Input: //¿¨Á´½Óhandle + ionum Êä³ö¿Ú±àºÅ +Output: //piValue Êä³ö¿Ú״̬ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetOp(ZMC_HANDLE handle, int ionum, uint32 *piValue); + + +/************************************************************* +Description: //¶ÁȡģÄâÁ¿ÊäÈëÐźŠ+Input: //¿¨Á´½Óhandle + ionum AIN¿Ú±àºÅ +Output: //pfValue ·µ»ØµÄÄ£ÄâÁ¿Öµ 4ϵÁÐÒÔÏÂ0-4095 +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetAD(ZMC_HANDLE handle, int ionum , float *pfValue); + +/************************************************************* +Description: //´ò¿ªÄ£ÄâÁ¿Êä³öÐźŠ+Input: //¿¨Á´½Óhandle + ionum DAÊä³ö¿Ú±àºÅ + fValue É趨µÄÄ£ÄâÁ¿Öµ4ϵÁÐÒÔÏÂ0-4095 +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetDA(ZMC_HANDLE handle, int ionum, float fValue); + + +/************************************************************* +Description: //¶ÁȡģÄâÊä³ö¿Ú״̬ +Input: //¿¨Á´½Óhandle + ionum Ä£ÄâÁ¿Êä³ö¿Ú±àºÅ +Output: //pfValue ¶ÁÈ¡µÄµÄÄ£ÄâÁ¿Öµ 4ϵÁÐÒÔÏÂ0-4095 +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetDA(ZMC_HANDLE handle, int ionum, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃÊäÈë¿Ú·´×ª +Input: //¿¨Á´½Óhandle + ionum ÊäÈë¿Ú±àºÅ + bifInvert ·´×ª×´Ì¬ 0/1 +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetInvertIn(ZMC_HANDLE handle, int ionum, int bifInvert); + +/************************************************************* +Description: //¶ÁÈ¡ÊäÈë¿Ú·´×ª×´Ì¬ +Input: //¿¨Á´½Óhandle + ionum ÊäÈë¿Ú±àºÅ +Output: //piValue ·´×ª×´Ì¬ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetInvertIn(ZMC_HANDLE handle, int ionum, int *piValue); + +/************************************************************* +Description: //ÉèÖÃpwmƵÂÊ +Input: //¿¨Á´½Óhandle + ionum PWM±àºÅ¿Ú + fValue ƵÂÊ Ó²¼þPWM1M ÈíPWM 2K +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetPwmFreq(ZMC_HANDLE handle, int ionum, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡pwmƵÂÊ +Input: //¿¨Á´½Óhandle + ionum PWM¿Ú±àºÅ +Output: //pfValue ·µ»ØµÄƵÂÊ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetPwmFreq(ZMC_HANDLE handle, int ionum, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃpwmÕ¼¿Õ±È +Input: //¿¨Á´½Óhandle + ionum PWM¿Ú±àºÅ + fValue Õ¼¿Õ±ä 0-1 0±íʾ¹Ø±ÕPWM¿Ú +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetPwmDuty(ZMC_HANDLE handle, int ionum, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡pwmÕ¼¿Õ±È +Input: //¿¨Á´½Óhandle + ionum PWM¿Ú±àºÅ +Output: //pfValue ¶ÁÈ¡µÄÕ¼¿Õ±È +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetPwmDuty(ZMC_HANDLE handle, int ionum, float *pfValue); + +#if 0 + //ͨ¹ýmodbus¿ìËÙ¶ÁÈ¡ÌØÊâ¼Ä´æÆ÷ +#endif + +/************************************************************* +Description: //²ÎÊý ¿ìËÙ¶ÁÈ¡¶à¸öÊäÈë +Input: //¿¨Á´½Óhandle + ionumfirst INÆðʼ±àºÅ + ionumend IN½áÊø±àºÅ +Output: //pValueList λ״̬ °´Î»´æ´¢ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_GetModbusIn(ZMC_HANDLE handle, int ionumfirst, int ionumend, uint8 *pValueList); + + +/************************************************************* +Description: //²ÎÊý ¿ìËÙ¶ÁÈ¡¶à¸öµ±Ç°µÄÊä³ö״̬ +Input: //¿¨Á´½Óhandle + ionumfirst INÆðʼ±àºÅ + ionumend IN½áÊø±àºÅ +Output: //pValueList λ״̬ °´Î»´æ´¢ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_GetModbusOut(ZMC_HANDLE handle, int ionumfirst, int ionumend, uint8 *pValueList); + + +/************************************************************* +Description: //²ÎÊý ¿ìËÙ¶ÁÈ¡¶à¸öµ±Ç°µÄDPOS +Input: //¿¨Á´½Óhandle + imaxaxises ÖáÊýÁ¿ +Output: //pValueList ¶ÁÈ¡µÄ×ø±êÖµ ´ÓÖá0¿ªÊ¼ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_GetModbusDpos(ZMC_HANDLE handle, int imaxaxises, float *pValueList); + + +/************************************************************* +Description: //²ÎÊý ¿ìËÙ¶ÁÈ¡¶à¸öµ±Ç°µÄMPOS +Input: //¿¨Á´½Óhandle + imaxaxises ÖáÊýÁ¿ +Output: //pValueList ¶ÁÈ¡µÄ·´À¡×ø±êÖµ ´ÓÖá0¿ªÊ¼ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_GetModbusMpos(ZMC_HANDLE handle, int imaxaxises, float *pValueList); + + +/************************************************************* +Description: //²ÎÊý ¿ìËÙ¶ÁÈ¡¶à¸öµ±Ç°µÄËÙ¶È +Input: //¿¨Á´½Óhandle + imaxaxises ÖáÊýÁ¿ +Output: //pValueList ¶ÁÈ¡µÄµ±Ç°ËÙ¶È ´ÓÖá0¿ªÊ¼ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_GetModbusCurSpeed(ZMC_HANDLE handle, int imaxaxises, float *pValueList); + + + +#if 0 + //²ÉÓÃZMC_DirectCommand À´¿ìËÙ»ñȡһЩ״̬, ZMC_DirectCommandµÄÖ´ÐбÈZMC_ExecuteÒª¿ì + // Ö»ÓвÎÊý£¬±äÁ¿£¬Êý×éÔªËØµÈÄÜʹÓÃZMC_DirectCommand + // 20130901ÒÔºóµÄ°æ±¾£¬Ò»Ð©Ô˶¯º¯ÊýÒ²¿ÉÒÔµ÷ÓÃZMC_DirectCommand£¬µ±Ô˶¯Ìõ¼þ²»Âú×ãµÄʱºò£¬»áÁ¢¿Ì·µ»ØÊ§°Ü¡£ + // ZMC_DirectCommandµ÷ÓÃÔ˶¯º¯Êýʱ£¬²ÎÊý±ØÐëÊǾßÌåµÄÊýÖµ£¬²»ÄÜÊDZäÁ¿±í´ïʽ¡£ +#endif + +/************************************************************* +Description: //ͨÓõIJÎÊýÐ޸ĺ¯Êý sParam: Ìîд²ÎÊýÃû³Æ +Input: //¿¨Á´½Óhandle + sParam Öá²ÎÊýÃû³Æ "DPOS" ... + iaxis ÖáºÅ + fset É趨ֵ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetParam(ZMC_HANDLE handle,const char *sParam,int iaxis, float fset); + +/************************************************************* +Description: //²ÎÊý ͨÓõIJÎÊý¶ÁÈ¡º¯Êý, sParam:Ìîд²ÎÊýÃû³Æ +Input: //¿¨Á´½Óhandle + sParam Öá²ÎÊýÃû³Æ "DPOS" ... + iaxis ÖáºÅ +Output: //pfValue ¶ÁÈ¡µÄ·µ»ØÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetParam(ZMC_HANDLE handle,const char *sParam, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖüÓËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue É趨ֵ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetAccel(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡¼ÓËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ¼ÓËÙ¶È·µ»ØÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetAccel(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡µþ¼ÓÖá +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //¶ÁÈ¡µÄÖáµþ¼ÓÖáºÅ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetAddax(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃÖá¸æ¾¯ÐźŠ+Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ±¨¾¯ÐźÅÊäÈë¿Ú±àºÅ£¬È¡ÏûʱÉ趨-1 +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetAlmIn(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡¸æ¾¯ÐźŠ+Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ±¨¾¯ÐźÅÊäÈë¿Ú·µ»ØÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetAlmIn(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃÖáÀàÐÍ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ÖáÀàÐÍ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetAtype(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡ÖáÀàÐÍ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //iValue ÖáÀàÐÍ·µ»ØÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetAtype(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //¶ÁÈ¡Öá״̬ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //Öá״̬·µ»ØÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetAxisStatus(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃÖáµØÖ· +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ÖáµØÖ·É趨ֵ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetAxisAddress(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡ÖáµØÖ· +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ÖáµØÖ··µ»ØÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetAxisAddress(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃÖáʹÄÜ £¨Ö»Õë¶Ô×ÜÏß¿ØÖÆÆ÷ÖáʹÓÃÓÐЧ£© +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ״̬ 0-¹Ø±Õ 1- ´ò¿ª +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetAxisEnable(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡ÖáʹÄÜ״̬ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄʹÄÜ״̬ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetAxisEnable(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃÁ´½ÓËÙÂÊ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ͬ²½Á¬½ÓËÙÂÊ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetClutchRate(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡Á´½ÓËÙÂÊ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue Á¬½ÓËÙÂÊ·µ»ØÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetClutchRate(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃËø´æ´¥·¢µÄ½áÊø×ø±ê·¶Î§µã +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue É趨µÄ·¶Î§Öµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetCloseWin(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡Ëø´æ´¥·¢µÄ½áÊø×ø±ê·¶Î§µã +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄ·¶Î§Öµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetCloseWin(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖùսǼõËÙ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ¹Õ½Ç¼õËÙģʽ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetCornerMode(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡¹Õ½Ç¼õËÙ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄ¹Õ½Çģʽ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetCornerMode(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖûØÁãÅÀÐÐËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValueÉèÖõÄËÙ¶ÈÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetCreep(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡»ØÁãÅÀÐÐËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄÅÀÐÐËÙ¶ÈÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetCreep(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃÔ­µãÐźŠÉ趨-1ΪȡÏûÔ­µãÉèÖà +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ÉèÖõÄÔ­µãÐźÅÊäÈë¿Ú±àºÅ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetDatumIn(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁȡԭµãÐźŠ+Input: //¿¨Á´½Óhandle + iaxis +Output: //piValue ·µ»ØÔ­µãÊäÈë¿Ú±àºÅ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetDatumIn(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖüõËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõļõËÙ¶ÈÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetDecel(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡¼õËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue É趨µÄ¼õËÙ¶È·µ»ØÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetDecel(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖùսǼõËٽǶȣ¬¿ªÊ¼¼õËٽǶȣ¬µ¥Î»Îª»¡¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõĹսǼõËÙ½Ç¶È +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetDecelAngle(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡¹Õ½Ç¿ªÊ¼¼õËٽǶȣ¬µ¥Î»Îª»¡¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄ¹Õ½Ç¼õËÙ½Ç¶È +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetDecelAngle(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃÖáλÖà +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõÄ×ø±êÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetDpos(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡ÖáλÖà +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄÃüÁîλÖÃ×ø±ê +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetDpos(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡ÄÚ²¿±àÂëÆ÷Öµ £¨×ÜÏß¾ø¶ÔÖµËÅ·þʱΪ¾ø¶ÔֵλÖã© +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄÄÚ²¿±àÂëÆ÷Öµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetEncoder(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡µ±Ç°Ô˶¯µÄ×îÖÕλÖà +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄ×îÖÕλÖà +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetEndMove(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡µ±Ç°ºÍ»º³åÖÐÔ˶¯µÄ×îÖÕλÖ㬿ÉÒÔÓÃÓÚÏà¶Ô¾ø¶Ôת»» +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄ×îÖÕλÖà +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetEndMoveBuffer(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃSPÔ˶¯µÄ½áÊøËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //fValue É趨µÄËÙ¶ÈÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetEndMoveSpeed(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡SPÔ˶¯µÄ½áÊøËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄËÙ¶ÈÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetEndMoveSpeed(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖôíÎó±ê¼Ç£¬ºÍAXISSTATUS×öÓëÔËËãÀ´¾ö¶¨ÄÄЩ´íÎóÐèÒª¹Ø±ÕWDOG¡£ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ÉèÖÃÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetErrormask(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡´íÎó±ê¼Ç£¬ºÍAXISSTATUS×öÓëÔËËãÀ´¾ö¶¨ÄÄЩ´íÎóÐèÒª¹Ø±ÕWDOG¡£ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄ±ê¼ÇÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetErrormask(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÿìËÙJOGÊäÈë +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ¿ìËÙJOGÊäÈë¿Ú±àºÅ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetFastJog(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡¿ìËÙJOGÊäÈë +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //·µ»ØµÄJOGÊäÈë¿Ú±àºÅ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetFastJog(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÿìËÙ¼õËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue É趨µÄ¿ìËÙ¼õËÙ¶È +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetFastDec(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡¿ìËÙ¼õËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄ¿ìËÙ¼õËÙ¶È +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetFastDec(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡Ëæ¶¯Îó²î +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄËæ¶¯Îó²î +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetFe(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃ×î´óÔÊÐíµÄËæ¶¯Îó²îÖµ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõÄ×î´óÎó²îÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetFeLimit(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡×î´óÔÊÐíµÄËæ¶¯Îó²îÖµ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄÉèÖÃ×î´óÎó²îÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetFeLimit(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖñ¨¾¯Ê±Ë涯Îó²îÖµ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõÄÎó²îÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetFRange(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡±¨¾¯Ê±µÄËæ¶¯Îó²îÖµ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄ±¨¾¯Îó²îÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetFeRange(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖñ£³ÖÊäÈë +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ÉèÖõÄÊäÈë¿Ú±àºÅ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetFholdIn(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡±£³ÖÊäÈë +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØÊäÈëHOLDINÊäÈë¿Ú±àºÅ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetFholdIn(ZMC_HANDLE handle, int iaxis, int *piValue); + + +/************************************************************* +Description: //ÉèÖÃÖá±£³ÖËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõÄËÙ¶ÈÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetFhspeed(ZMC_HANDLE handle, int iaxis, float pfValue); + +/************************************************************* +Description: //¶ÁÈ¡Öá±£³ÖËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄ±£³ÖËÙ¶È +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetFhspeed(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃSPÔ˶¯µÄÔËÐÐËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõÄËÙ¶ÈÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetForceSpeed(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡SPÔ˶¯µÄÔËÐÐËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØSPÔ˶¯ËÙ¶ÈÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetForceSpeed(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃÕýÏòÈíÏÞλ È¡ÏûʱÉèÖÃÒ»¸ö½Ï´óÖµ¼´¿É +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue É趨µÄÏÞλֵ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetFsLimit(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡ÕýÏòÈíÏÞλ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄÕýÏòÏÞÎ»×ø±ê +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetFsLimit(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃСԲÏÞËÙ×îС°ë¾¶ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõÄ×îС°ë¾¶ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetFullSpRadius(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁȡСԲÏÞËÙ×îС°ë¾¶ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄÏÞËÙ°ë¾¶ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetFullSpRadius(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃÕýÏòÓ²ÏÞλÊäÈë ÉèÖóÉ-1ʱ±íʾ²»ÉèÖÃÏÞλ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ÉèÖõÄÏÞλÊäÈë¿Ú±àºÅ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetFwdIn(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡ÕýÏòÓ²ÏÞλÊäÈë +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØÕýÏòÏÞλÊäÈë¿Ú±àºÅ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetFwdIn(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃÕýÏòJOGÊäÈë +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ÉèÖõÄJOGÊäÈë¿Ú±àºÅ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetFwdJog(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡ÕýÏòJOGÊäÈë +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄJOGÊäÈë¿Ú±àºÅ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetFwdJog(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //¶ÁÈ¡ÖáÊÇ·ñÔ˶¯½áÊø +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØÔËÐÐ״̬ 0-Ô˶¯ÖÐ -1 Í£Ö¹ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetIfIdle(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃÂö³åÊä³öģʽ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue É趨µÄÂö³åÊä³öģʽ Âö³å+·½Ïò/Ë«Âö³å +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetInvertStep(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡Âö³åÊä³öģʽ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄÂö³åģʽ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetInvertStep(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖò岹ʱÖáÊÇ·ñ²ÎÓëËٶȼÆË㣬ȱʡ²ÎÓ루1£©¡£´Ë²ÎÊýÖ»¶ÔÖ±ÏߺÍÂÝÐýµÄµÚÈý¸öÖáÆð×÷Óà +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ģʽ 0-²»²ÎÊý 1-²ÎÓë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetInterpFactor(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡²å²¹Ê±ÖáÊÇ·ñ²ÎÓëËٶȼÆË㣬ȱʡ²ÎÓ루1£©¡£´Ë²ÎÊýÖ»¶ÔÖ±ÏߺÍÂÝÐýµÄµÚÈý¸öÖáÆð×÷Óà +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄËٶȼÆËãģʽ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetInterpFactor(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃJOGʱËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //fValue É趨µÄËÙ¶ÈÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetJogSpeed(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡JOGʱËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄJOGËÙ¶ÈÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetJogSpeed(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡µ±Ç°Á´½ÓÔ˶¯µÄ²Î¿¼ÖáºÅ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØÁ´½ÓµÄ²Î¿¼ÖáºÅ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetLinkax(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //¶ÁÈ¡µ±Ç°³ýÁ˵±Ç°Ô˶¯ÊÇ·ñ»¹Óлº³å +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»Ø×´Ì¬Öµ -1 ûÓÐÊ£ÓຯÊý 0-»¹ÓÐÊ£ÓàÔ˶¯ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetLoaded(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃÖáÆðʼËÙ¶È +Input: //¿¨Á´½Óhandle +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetLspeed(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡ÖáÆðʼËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄÆðʼËÙ¶ÈÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetLspeed(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖûØÁã·´Õҵȴýʱ¼ä +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue »ØÁã·´Õҵȴýʱ¼ä MS +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetHomeWait(ZMC_HANDLE handle, int iaxis, int fValue); + +/************************************************************* +Description: //¶ÁÈ¡»ØÁã·´Õҵȴýʱ¼ä +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄ·´Õҵȴýʱ¼ä +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetHomeWait(ZMC_HANDLE handle, int iaxis, int *pfValue); + + +/************************************************************* +Description: //¶ÁÈ¡±àÂëÆ÷Ëø´æÊ¾½Ì·µ»Ø×´Ì¬ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄËø´æ´¥·¢×´Ì¬ -1-Ëø´æ´¥·¢ 0-δ´¥·¢ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetMark(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //¶ÁÈ¡±àÂëÆ÷Ëø´æb·µ»Ø×´Ì¬ +Input: //¿¨Á´½Óhandle +iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄËø´æ´¥·¢×´Ì¬ -1-Ëø´æ´¥·¢ 0-δ´¥·¢ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetMarkB(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃÂö³åÊä³ö×î¸ßƵÂÊ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ÉèÖõÄ×î¸ßÂö³åƵÂÊ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetMaxSpeed(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡Âö³åÊä³ö×î¸ßƵÂÊ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄÂö³åƵÂÊ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetMaxSpeed(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃÁ¬Ðø²å²¹ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue Á¬Ðø²å²¹¿ª¹Ø 0-¹Ø±ÕÁ¬Ðø²å²¹ 1-´ò¿ªÁ¬Ðø²å²¹ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetMerge(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡Á¬Ðø²å²¹×´Ì¬ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄÁ¬Ðø²å²¹¿ª¹Ø×´Ì¬ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetMerge(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //¶ÁÈ¡µ±Ç°±»»º³åÆðÀ´µÄÔ˶¯¸öÊý +Input: //¿¨Á´½Óhandle + iaxis ÖáÊý +Output: //piValue »º³åÔ˶¯Êý +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetMovesBuffered(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //¶ÁÈ¡µ±Ç°ÕýÔÚÔ˶¯Ö¸ÁîµÄMOVE_MARK±êºÅ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue µ±Ç°MARK±êºÅ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetMoveCurmark(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃÔ˶¯Ö¸ÁîµÄMOVE_MARK±êºÅ ÿµ±ÓÐÔ˶¯½øÈëÖáÔ˶¯»º³åʱMARK×Ô¶¯+1 +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue É趨µÄMARKÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetMovemark(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //ÉèÖ÷´À¡Î»Öà +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõķ´À¡Î»Öà +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetMpos(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡·´À¡Î»Öà +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄÖá·´À¡Î»ÖÃ×ø±ê +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetMpos(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡·´À¡ËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄ±àÂëÆ÷·´À¡ËÙ¶È +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetMspeed(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡µ±Ç°ÕýÔÚÔ˶¯Ö¸ÁîÀàÐÍ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»Øµ±Ç°µÄÔ˶¯ÀàÐÍ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetMtype(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //¶ÁÈ¡µ±Ç°ÕýÔÚ½øÐеÄÔ˶¯Ö¸ÁîºóÃæµÄµÚÒ»ÌõÖ¸ÁîÀàÐÍ£¬µ±²å²¹Áª¶¯Ê±£¬¶Ô´ÓÖá×ÜÊÇ·µ»ØÖ÷ÖáµÄÔ˶¯Ö¸ÁîÀàÐÍ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØÏÂÒ»ÌõÖ¸ÁîµÄÔ˶¯ÀàÐÍ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetNtype(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃÐÞ¸ÄÆ«ÒÆÎ»Öà +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖÃµÄÆ«ÒÆÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetOffpos(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡ÐÞ¸ÄÆ«ÒÆÎ»Öà +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄÆ«ÒÆ×ø±êÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetOffpos(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃËø´æ´¥·¢µÄ½áÊø×ø±ê·¶Î§µã¡£ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõÄ×ø±êÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetOpenWin(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡Ëø´æ´¥·¢µÄ½áÊø×ø±ê·¶Î§µã¡£ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄ½áÊø×ø±êÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetOpenWin(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡·µ»ØËø´æµÄ²âÁ¿·´À¡Î»ÖÃ(MPOS) +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue Ëø´æµÄ×ø±êλÖà +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetRegPos(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡·µ»ØËø´æµÄ²âÁ¿·´À¡Î»ÖÃ(MPOS) +Input: //¿¨Á´½Óhandle +iaxis ÖáºÅ +Output: //pfValue Ëø´æµÄ×ø±êλÖà +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetRegPosB(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡·µ»ØÖᵱǰÔ˶¯»¹Î´Íê³ÉµÄ¾àÀë +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄÊ£Óà¾àÀë +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetRemain(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //²ÎÊý ÖáÊ£ÓàµÄ»º³å, °´Ö±Ïß¶ÎÀ´¼ÆËã +REMAIN_BUFFERΪΨһһ¸ö¿ÉÒÔ¼ÓAXIS²¢ÓÃZAux_DirectCommand»ñÈ¡µÄ. +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue Ê£ÓàµÄÖ±Ïß»º³åÊýÁ¿ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetRemain_LineBuffer(ZMC_HANDLE handle, int iaxis,int * piValue); + +/************************************************************* +Description: //²ÎÊý ÖáÊ£ÓàµÄ»º³å, °´×ÔӵĿռäÔ²»¡À´¼ÆËã +REMAIN_BUFFERΪΨһһ¸ö¿ÉÒÔ¼ÓAXIS²¢ÓÃZAux_DirectCommand»ñÈ¡µÄ. +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue Ê£ÓàµÄ»º³åÊýÁ¿ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetRemain_Buffer(ZMC_HANDLE handle, int iaxis, int *piValue); + + +/************************************************************* +Description: //ÉèÖøù¾ÝREP_OPTIONÉèÖÃÀ´×Ô¶¯Ñ­»·ÖáDPOSºÍMPOS×ø±ê¡£ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõÄ×ø±êÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetRepDist(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡¸ù¾ÝREP_OPTIONÉèÖÃÀ´×Ô¶¯Ñ­»·ÖáDPOSºÍMPOS×ø±ê¡£ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄÑ­»·×ø±êÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetRepDist(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃ×ø±êÖØ¸´ÉèÖà +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ģʽ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetRepOption(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡×ø±êÖØ¸´ÉèÖà +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄģʽ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetRepOption(ZMC_HANDLE handle, int iaxis, int *piValue); + + +/************************************************************* +Description: //ÉèÖøºÏòÓ²¼þÏÞλ¿ª¹Ø¶ÔÓ¦µÄÊäÈëµã±àºÅ£¬-1ÎÞЧ¡£ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ÉèÖõÄÊäÈë¿Ú±àºÅ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetRevIn(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡¸ºÏòÓ²¼þÏÞλ¿ª¹Ø¶ÔÓ¦µÄÊäÈëµã±àºÅ£¬-1ÎÞЧ¡£ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄ¸ºÏòÏÞλÊäÈë¿Ú±àºÅ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetRevIn(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖøºÏòJOGÊäÈë¶ÔÓ¦µÄÊäÈëµã±àºÅ£¬-1ÎÞЧ¡£ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + iValue ÉèÖõÄÊäÈë¿Ú±àºÅ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetRevJog(ZMC_HANDLE handle, int iaxis, int iValue); + +/************************************************************* +Description: //¶ÁÈ¡¸ºÏòJOGÊäÈë¶ÔÓ¦µÄÊäÈëµã±àºÅ£¬-1ÎÞЧ¡£ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄÊäÈë¿Ú±àºÅ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetRevJog(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖøºÏòÈíÏÞλλÖᣠÉèÖÃÒ»¸ö½Ï´óµÄֵʱÈÏΪȡÏûÏÞλ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ¸ºÏòÏÞλֵ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetRsLimit(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡¸ºÏòÈíÏÞλλÖᣠ+Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue É趨µÄÏÞλֵ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetRsLimit(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃÖáËÙ¶È£¬µ¥Î»Îªunits/s£¬µ±¶àÖáÔ˶¯Ê±£¬×÷Ϊ²å²¹Ô˶¯µÄËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõÄËÙ¶ÈÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetSpeed(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡ÖáËÙ¶È£¬µ¥Î»Îªunits/s£¬µ±¶àÖáÔ˶¯Ê±£¬×÷Ϊ²å²¹Ô˶¯µÄËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄËÙ¶ÈÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetSpeed(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖà SÇúÏßÉèÖᣠ0-ÌÝÐμӼõËÙ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue SÇúÏ߯½»¬Ê±¼äMS +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetSramp(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡ SÇúÏßÉèÖᣠ+Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ƽ»¬Ê±¼ä +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetSramp(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖà ×Ô¶¨ÒåËٶȵÄSPÔ˶¯µÄÆðʼËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõÄËÙ¶ÈÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetStartMoveSpeed(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡×Ô¶¨ÒåËٶȵÄSPÔ˶¯µÄÆðʼËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄSPÔ˶¯ÆðʼËÙ¶ÈÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetStartMoveSpeed(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖà ¼õËÙµ½×îµÍµÄ×îС¹Õ½Ç »¡¶ÈÖÆ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõĽǶÈÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetStopAngle(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡¼õËÙµ½×îµÍµÄ×îС¹Õ½Ç »¡¶ÈÖÆ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄ¹Õ½ÇÍ£Ö¹½Ç¶È +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetStopAngle(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖà ¼õËÙµ¹½Ç°ë¾¶ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue µ¹½Ç°ë¾¶ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetZsmooth(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡µ¹½Ç°ë¾¶ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄµ¹½Ç°ë¾¶Öµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetZsmooth(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //ÉèÖà Âö³åµ±Á¿ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue ÉèÖõĵ±Á¿Öµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetUnits(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡Âö³åµ±Á¿ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄÂö³åµ±Á¿ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetUnits(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡·µ»ØÖᵱǰµ±Ç°Ô˶¯ºÍ»º³åÔ˶¯»¹Î´Íê³ÉµÄ¾àÀë +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄÕóÓê¾àÀë +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetVectorBuffered(ZMC_HANDLE handle, int iaxis, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡µ±Ç°ÖáÔËÐеÄÃüÁîËÙ¶È +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //pfValue ·µ»ØµÄµ±Ç°ËÙ¶ÈÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetVpSpeed(ZMC_HANDLE handle, int iaxis, float *pfValue); + + + + +/************************************************************* +Description: //È«¾Ö±äÁ¿¶ÁÈ¡, Ò²¿ÉÒÔÊDzÎÊýµÈµÈ +Input: //¿¨Á´½Óhandle + pname È«¾Ö±äÁ¿Ãû³Æ/»òÕßÖ¸¶¨ÖáºÅµÄÖá²ÎÊýÃû³ÆDPOS(0) +Output: //pfValue ·µ»ØÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetVariablef(ZMC_HANDLE handle, const char *pname, float *pfValue); + + +/************************************************************* +Description: //È«¾Ö±äÁ¿¶ÁÈ¡, Ò²¿ÉÒÔÊDzÎÊýµÈµÈ +Input: //¿¨Á´½Óhandle + pname È«¾Ö±äÁ¿Ãû³Æ/»òÕßÖ¸¶¨ÖáºÅµÄÖá²ÎÊýÃû³ÆDPOS(0) +Output: //piValue ·µ»ØÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetVariableInt(ZMC_HANDLE handle, const char *pname, int *piValue); + +/////////////////////// Ö»ÓÐÏÂÃæµÄÔ˶¯º¯ÊýÖ§³ÖÖ±½Óµ÷Ó㬲¢²»ÊÇËùÓеÄÖ¸Áî¶¼Ö§³Ö +/////////////////////// ±ØÐë 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö + +/************************************************************* +Description: //BASEÖ¸Áîµ÷Óà +½ö½öÐÞ¸ÄÔÚÏßÃüÁîµÄBASEÁÐ±í£¬²»¶Ô¿ØÖÆÆ÷µÄÔËÐÐÈÎÎñµÄBASE½øÐÐÐÞ¸Ä. +Ð޸ĺ󣬺óÐøµÄËùÓÐMOVEµÈÖ¸Áî¶¼ÊÇÒÔÕâ¸öBASEΪ»ù´¡ +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÖáÊý + piAxislist ÖáÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Base(ZMC_HANDLE handle, int imaxaxises, int *piAxislist); + +/************************************************************* +Description: //¶¨ÒåDPOS,²»½¨ÒéʹÓ㬿ÉÒÔÖ±½Óµ÷ÓÃSETDPOS´ïµ½Í¬ÑùЧ¹û +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + pfDpos ÉèÖõÄ×ø±êÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Defpos(ZMC_HANDLE handle, int iaxis, float pfDpos); + +/************************************************************* +Description: //¶àÖáÏà¶ÔÖ±Ï߲岹 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + pfDisancelist ¾àÀëÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Move(ZMC_HANDLE handle, int imaxaxises, int *piAxislist,float *pfDisancelist); + + +/************************************************************* +Description: //Ïà¶Ô¶àÖáÖ±Ï߲岹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + pfDisancelist ¾àÀëÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float *pfDisancelist); + + + +/************************************************************* +Description: //¾ø¶Ô¶àÖáÖ±Ï߲岹 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + pfDisancelist ¾àÀëÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveAbs(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float *pfDisancelist); + + +/************************************************************* +Description: //¾ø¶Ô¶àÖáÖ±Ï߲岹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + pfDisancelist ¾àÀëÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveAbsSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float *pfDisancelist); + + + +/************************************************************* +//Description: //Ô˶¯ÖÐÐ޸ĽáÊøÎ»Öà 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +//Input: //¿¨Á´½Óhandle + ÖáºÅ iaxis + ¾ø¶Ô¾àÀë pfDisance +//Output: // +//Return: //´íÎóÂë +/*************************************************************/ +int32 __stdcall ZAux_Direct_MoveModify(ZMC_HANDLE handle, int iaxis, float pfDisance); + + +/************************************************************* +Description: //Ïà¶ÔÔ²ÐĶ¨Ô²»¡²å²¹Ô˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + end1 µÚÒ»¸öÖáÔ˶¯×ø±ê + end2 µÚ¶þ¸öÖáÔ˶¯×ø±ê + centre1 µÚÒ»¸öÖáÔ˶¯Ô²ÐÄ£¬Ïà¶ÔÓëÆðʼµã¡£ + centre2 µÚ¶þ¸öÖáÔ˶¯Ô²ÐÄ£¬Ïà¶ÔÓëÆðʼµã¡£ + direction 0-ÄæÊ±Õ룬1-˳ʱÕë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveCirc(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection); + + +/************************************************************* +Description: //Ïà¶ÔÔ²ÐĶ¨Ô²»¡²å²¹Ô˶¯ ²å²¹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + end1 µÚÒ»¸öÖáÔ˶¯×ø±ê + end2 µÚ¶þ¸öÖáÔ˶¯×ø±ê + centre1 µÚÒ»¸öÖáÔ˶¯Ô²ÐÄ£¬Ïà¶ÔÓëÆðʼµã¡£ + centre2 µÚ¶þ¸öÖáÔ˶¯Ô²ÐÄ£¬Ïà¶ÔÓëÆðʼµã¡£ + direction 0-ÄæÊ±Õ룬1-˳ʱÕë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveCircSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection); + + + +/************************************************************* +Description: //¾ø¶ÔÔ²ÐÄÔ²»¡²å²¹Ô˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö ÎÞ·¨»­ÕûÔ² +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + end1 µÚÒ»¸öÖáÔ˶¯×ø±ê£¬¾ø¶ÔλÖà + end2 µÚ¶þ¸öÖáÔ˶¯×ø±ê£¬¾ø¶ÔλÖà + centre1 µÚÒ»¸öÖáÔ˶¯Ô²ÐÄ£¬¾ø¶ÔλÖà + centre2 µÚ¶þ¸öÖáÔ˶¯Ô²ÐÄ£¬¾ø¶ÔλÖà + direction 0-ÄæÊ±Õ룬1-˳ʱÕë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveCircAbs(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection); + + +/************************************************************* +Description: //¾ø¶ÔÔ²ÐÄÔ²»¡²å²¹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö ÎÞ·¨»­ÕûÔ² +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + end1 µÚÒ»¸öÖáÔ˶¯×ø±ê£¬¾ø¶ÔλÖà + end2 µÚ¶þ¸öÖáÔ˶¯×ø±ê£¬¾ø¶ÔλÖà + centre1 µÚÒ»¸öÖáÔ˶¯Ô²ÐÄ£¬¾ø¶ÔλÖà + centre2 µÚ¶þ¸öÖáÔ˶¯Ô²ÐÄ£¬¾ø¶ÔλÖà + direction 0-ÄæÊ±Õ룬1-˳ʱÕë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveCircAbsSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection); + + + + +/************************************************************* +Description: //Ïà¶Ô3µã¶¨Ô²»¡²å²¹Ô˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + mid1 µÚÒ»¸öÖáÖмäµã£¬Ïà¶ÔÆðʼµã¾àÀë + mid2 µÚ¶þ¸öÖáÖмäµã£¬Ïà¶ÔÆðʼµã¾àÀë + end1 µÚÒ»¸öÖá½áÊøµã£¬Ïà¶ÔÆðʼµã¾àÀë + end2 µÚ¶þ¸öÖá½áÊøµã£¬Ïà¶ÔÆðʼµã¾àÀë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveCirc2(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fmid1, float fmid2, float fend1, float fend2); + + + +/************************************************************* +Description: //¾ø¶Ô3µã¶¨Ô²»¡²å²¹Ô˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + mid1 µÚÒ»¸öÖáÖмäµã£¬¾ø¶ÔλÖà + mid2 µÚ¶þ¸öÖáÖмäµã£¬¾ø¶ÔλÖà + end1 µÚÒ»¸öÖá½áÊøµã£¬¾ø¶ÔλÖà + end2 µÚ¶þ¸öÖá½áÊøµã£¬¾ø¶ÔλÖà +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveCirc2Abs(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fmid1, float fmid2, float fend1, float fend2); + + +/************************************************************* +Description: //Ïà¶Ô3µã¶¨Ô²»¡²å²¹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + mid1 µÚÒ»¸öÖáÖмäµã£¬Ïà¶ÔÆðʼµã¾àÀë + mid2 µÚ¶þ¸öÖáÖмäµã£¬Ïà¶ÔÆðʼµã¾àÀë + end1 µÚÒ»¸öÖá½áÊøµã£¬Ïà¶ÔÆðʼµã¾àÀë + end2 µÚ¶þ¸öÖá½áÊøµã£¬Ïà¶ÔÆðʼµã¾àÀë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveCirc2Sp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fmid1, float fmid2, float fend1, float fend2); + + + +/************************************************************* +Description: //¾ø¶Ô3µã¶¨Ô²»¡²å²¹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + mid1 µÚÒ»¸öÖáÖмäµã£¬¾ø¶ÔλÖà + mid2 µÚ¶þ¸öÖáÖмäµã£¬¾ø¶ÔλÖà + end1 µÚÒ»¸öÖá½áÊøµã£¬¾ø¶ÔλÖà + end2 µÚ¶þ¸öÖá½áÊøµã£¬¾ø¶ÔλÖà +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveCirc2AbsSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fmid1, float fmid2, float fend1, float fend2); + + +/************************************************************* +Description: //Ïà¶Ô3ÖáÔ²ÐÄÂÝÐý²å²¹Ô˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + end1 µÚÒ»¸öÖáÔ˶¯×ø±ê + end2 µÚ¶þ¸öÖáÔ˶¯×ø±ê + centre1 µÚÒ»¸öÖáÔ˶¯Ô²ÐÄ£¬Ïà¶ÔÓëÆðʼµã + centre2 µÚ¶þ¸öÖáÔ˶¯Ô²ÐÄ£¬Ïà¶ÔÓëÆðʼµã + direction 0-ÄæÊ±Õ룬1-˳ʱÕë + distance3µÚÈý¸öÖáÔ˶¯¾àÀë¡£ + mode µÚÈýÖáµÄËٶȼÆËã:0(ȱʡ)µÚÈýÖá²ÎÓëËٶȼÆËã¡£1µÚÈýÖá²»²ÎÓëËٶȼÆËã¡£ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MHelical(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection, float fDistance3, int imode); + + +/************************************************************* +Description: //¾ø¶Ô3ÖáÔ²ÐÄÂÝÐý²å²¹Ô˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + end1 µÚÒ»¸öÖáÔ˶¯×ø±ê + end2 µÚ¶þ¸öÖáÔ˶¯×ø±ê + centre1 µÚÒ»¸öÖáÔ˶¯Ô²ÐÄ×ø±ê + centre2 µÚ¶þ¸öÖáÔ˶¯Ô²ÐÄ×ø±ê + direction 0-ÄæÊ±Õ룬1-˳ʱÕë + distance3µÚÈý¸öÖáÔ˶¯¾àÀë¡£ + mode µÚÈýÖáµÄËٶȼÆËã:0(ȱʡ) µÚÈýÖá²ÎÓëËٶȼÆËã¡£1µÚÈýÖá²»²ÎÓëËٶȼÆËã¡£ +Output: // + Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MHelicalAbs(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection, float fDistance3, int imode); + + +/************************************************************* +Description: //Ïà¶Ô3ÖáÔ²ÐÄÂÝÐý²å²¹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + end1 µÚÒ»¸öÖáÔ˶¯×ø±ê + end2 µÚ¶þ¸öÖáÔ˶¯×ø±ê + centre1 µÚÒ»¸öÖáÔ˶¯Ô²ÐÄ£¬Ïà¶ÔÓëÆðʼµã + centre2 µÚ¶þ¸öÖáÔ˶¯Ô²ÐÄ£¬Ïà¶ÔÓëÆðʼµã + direction 0-ÄæÊ±Õ룬1-˳ʱÕë + distance3µÚÈý¸öÖáÔ˶¯¾àÀë¡£ + mode µÚÈýÖáµÄËٶȼÆËã: 0(ȱʡ)µÚÈýÖá²ÎÓëËٶȼÆËã¡£ 1µÚÈýÖá²»²ÎÓëËٶȼÆËã¡£ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MHelicalSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection, float fDistance3, int imode); + + +/************************************************************* +Description: //¾ø¶Ô3ÖáÔ²ÐÄÂÝÐý²å²¹Ô˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + end1 µÚÒ»¸öÖáÔ˶¯×ø±ê + end2 µÚ¶þ¸öÖáÔ˶¯×ø±ê + centre1 µÚÒ»¸öÖáÔ˶¯Ô²ÐÄ×ø±ê + centre2 µÚ¶þ¸öÖáÔ˶¯Ô²ÐÄ×ø±ê + direction 0-ÄæÊ±Õ룬1-˳ʱÕë + distance3µÚÈý¸öÖáÔ˶¯¾àÀë¡£ + mode µÚÈýÖáµÄËٶȼÆËã:0(ȱʡ) µÚÈýÖá²ÎÓëËٶȼÆËã¡£1µÚÈýÖá²»²ÎÓëËٶȼÆËã¡£ +Output: // + Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MHelicalAbsSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection, float fDistance3, int imode); + + + +/************************************************************* +Description: //Ïà¶Ô3Öá 3µã»­ÂÝÐý²å²¹Ô˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + mid1 µÚÒ»¸öÖáÖмäµã + mid2 µÚ¶þ¸öÖáÖмäµã + end1 µÚÒ»¸öÖá½áÊøµã + end2 µÚ¶þ¸öÖá½áÊøµã + distance3µÚÈý¸öÖáÔ˶¯¾àÀë + mode µÚÈýÖáµÄËٶȼÆËã: +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MHelical2(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fmid1, float fmid2, float fend1, float fend2, float fDistance3, int imode); + + +/************************************************************* +Description: //¾ø¶Ô3Öá 3µã»­ÂÝÐý²å²¹Ô˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + mid1 µÚÒ»¸öÖáÖмäµã + mid2 µÚ¶þ¸öÖáÖмäµã + end1 µÚÒ»¸öÖá½áÊøµã + end2 µÚ¶þ¸öÖá½áÊøµã + distance3 µÚÈý¸öÖáÔ˶¯½áÊøµã + mode µÚÈýÖáµÄËٶȼÆËã: +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MHelical2Abs(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fmid1, float fmid2, float fend1, float fend2, float fDistance3, int imode); + + +/************************************************************* +Description: //Ïà¶Ô3Öá 3µã»­ÂÝÐý²å²¹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + mid1 µÚÒ»¸öÖáÖмäµã + mid2 µÚ¶þ¸öÖáÖмäµã + end1 µÚÒ»¸öÖá½áÊøµã + end2 µÚ¶þ¸öÖá½áÊøµã + distance3µÚÈý¸öÖáÔ˶¯¾àÀë + mode µÚÈýÖáµÄËٶȼÆËã: +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MHelical2Sp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fmid1, float fmid2, float fend1, float fend2, float fDistance3, int imode); + + +/************************************************************* +Description: //¾ø¶Ô3Öá 3µã»­ÂÝÐý²å²¹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + mid1 µÚÒ»¸öÖáÖмäµã + mid2 µÚ¶þ¸öÖáÖмäµã + end1 µÚÒ»¸öÖá½áÊøµã + end2 µÚ¶þ¸öÖá½áÊøµã + distance3 µÚÈý¸öÖáÔ˶¯½áÊøµã + mode µÚÈýÖáµÄËٶȼÆËã: +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MHelical2AbsSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fmid1, float fmid2, float fend1, float fend2, float fDistance3, int imode); + + + + +/************************************************************* +Description: //Ïà¶ÔÍÖÔ²²å²¹ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + fend1 ÖÕµãµÚÒ»¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + fend2 ÖÕµãµÚ¶þ¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + fcenter1 ÖÐÐĵÚÒ»¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + fcenter2 ÖÐÐĵڶþ¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + idirection 0-ÄæÊ±Õ룬1-˳ʱÕë + fADis µÚÒ»ÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É¡£ + fBDis µÚ¶þÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É£¬ABÏàµÈʱ×Ô¶¯ÎªÔ²»¡»òÂÝÐý¡£ + fend µÚÈý¸öÖáµÄÔ˶¯¾àÀ룬ÐèÒªÂÝÐýʱÌîÈë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MEclipse(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection, float fADis, float fBDis,float fend); + + +/************************************************************* +Description: //¾ø¶ÔÍÖÔ²²å²¹ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + fend1 ÖÕµãµÚÒ»¸öÖáÔ˶¯×ø±ê + fend2 ÖÕµãµÚ¶þ¸öÖáÔ˶¯×ø±ê + fcenter1 ÖÐÐĵÚÒ»¸öÖáÔ˶¯×ø±ê¡£ + fcenter2 ÖÐÐĵڶþ¸öÖáÔ˶¯×ø±ê¡£ + idirection 0-ÄæÊ±Õ룬1-˳ʱÕë + fADis µÚÒ»ÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É¡£ + fBDis µÚ¶þÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É£¬ABÏàµÈʱ×Ô¶¯ÎªÔ²»¡»òÂÝÐý¡£ + fend µÚÈý¸öÖáµÄÔ˶¯×ø±ê£¬ÐèÒªÂÝÐýʱÌîÈë + Output: // + Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MEclipseAbs(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection, float fADis, float fBDis,float fend); + + +/************************************************************* +Description: //Ïà¶ÔÍÖÔ²²å²¹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + fend1 ÖÕµãµÚÒ»¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + fend2 ÖÕµãµÚ¶þ¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + fcenter1 ÖÐÐĵÚÒ»¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + fcenter2 ÖÐÐĵڶþ¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + idirection 0-ÄæÊ±Õ룬1-˳ʱÕë + fADis µÚÒ»ÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É¡£ + fBDis µÚ¶þÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É£¬ABÏàµÈʱ×Ô¶¯ÎªÔ²»¡»òÂÝÐý¡£ + fend µÚÈý¸öÖáµÄÔ˶¯×ø±ê£¬ÐèÒªÂÝÐýʱÌîÈë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MEclipseSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection, float fADis, float fBDis,float fend); + + +/************************************************************* +Description: //¾ø¶ÔÍÖÔ²²å²¹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + fend1 ÖÕµãµÚÒ»¸öÖáÔ˶¯×ø±ê + fend2 ÖÕµãµÚ¶þ¸öÖáÔ˶¯×ø±ê + fcenter1 ÖÐÐĵÚÒ»¸öÖáÔ˶¯×ø±ê¡£ + fcenter2 ÖÐÐĵڶþ¸öÖáÔ˶¯×ø±ê¡£ + idirection 0-ÄæÊ±Õ룬1-˳ʱÕë + fADis µÚÒ»ÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É¡£ + fBDis µÚ¶þÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É£¬ABÏàµÈʱ×Ô¶¯ÎªÔ²»¡»òÂÝÐý¡£ + fend µÚÈý¸öÖáµÄÔ˶¯×ø±ê£¬ÐèÒªÂÝÐýʱÌîÈë + Output: // + Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MEclipseAbsSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection, float fADis, float fBDis,float fend); + + + +/************************************************************* +Description: //Ïà¶Ô ÍÖÔ² + ÂÝÐý²å²¹Ô˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + fend1 ÖÕµãµÚÒ»¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + fend2 ÖÕµãµÚ¶þ¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + fcenter1 ÖÐÐĵÚÒ»¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + fcenter2 ÖÐÐĵڶþ¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + idirection 0-ÄæÊ±Õ룬1-˳ʱÕë + fADis µÚÒ»ÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É¡£ + fBDis µÚ¶þÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É£¬ABÏàµÈʱ×Ô¶¯ÎªÔ²»¡»òÂÝÐý¡£ + fDistance3 µÚÈý¸öÖáµÄÔ˶¯¾àÀë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MEclipseHelical(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection, float fADis, float fBDis, float fDistance3); + + +/************************************************************* +Description: //¾ø¶ÔÍÖÔ² + ÂÝÐý²å²¹Ô˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + fend1 ÖÕµãµÚÒ»¸öÖáÔ˶¯×ø±ê + fend2 ÖÕµãµÚ¶þ¸öÖáÔ˶¯×ø±ê + fcenter1 ÖÐÐĵÚÒ»¸öÖáÔ˶¯×ø±ê¡£ + fcenter2 ÖÐÐĵڶþ¸öÖáÔ˶¯×ø±ê¡£ + idirection 0-ÄæÊ±Õ룬1-˳ʱÕë + fADis µÚÒ»ÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É¡£ + fBDis µÚ¶þÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É£¬ABÏàµÈʱ×Ô¶¯ÎªÔ²»¡»òÂÝÐý¡£ + fDistance3 µÚÈý¸öÖáµÄÔ˶¯¾àÀë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MEclipseHelicalAbs(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection, float fADis, float fBDis,float fDistance3); + + +/************************************************************* +Description: //Ïà¶Ô ÍÖÔ² + ÂÝÐý²å²¹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + fend1 ÖÕµãµÚÒ»¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + fend2 ÖÕµãµÚ¶þ¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + fcenter1 ÖÐÐĵÚÒ»¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + fcenter2 ÖÐÐĵڶþ¸öÖáÔ˶¯×ø±ê£¬Ïà¶ÔÓÚÆðʼµã¡£ + idirection 0-ÄæÊ±Õ룬1-˳ʱÕë + fADis µÚÒ»ÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É¡£ + fBDis µÚ¶þÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É£¬ABÏàµÈʱ×Ô¶¯ÎªÔ²»¡»òÂÝÐý¡£ + fDistance3 µÚÈý¸öÖáµÄÔ˶¯¾àÀë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MEclipseHelicalSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection, float fADis, float fBDis, float fDistance3); + + +/************************************************************* +Description: //¾ø¶ÔÍÖÔ² + ÂÝÐý²å²¹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + fend1 ÖÕµãµÚÒ»¸öÖáÔ˶¯×ø±ê + fend2 ÖÕµãµÚ¶þ¸öÖáÔ˶¯×ø±ê + fcenter1 ÖÐÐĵÚÒ»¸öÖáÔ˶¯×ø±ê¡£ + fcenter2 ÖÐÐĵڶþ¸öÖáÔ˶¯×ø±ê¡£ + idirection 0-ÄæÊ±Õ룬1-˳ʱÕë + fADis µÚÒ»ÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É¡£ + fBDis µÚ¶þÖáµÄÍÖÔ²°ë¾¶£¬°ë³¤Öá»òÕß°ë¶ÌÖá¶¼¿É£¬ABÏàµÈʱ×Ô¶¯ÎªÔ²»¡»òÂÝÐý¡£ + fDistance3 µÚÈý¸öÖáµÄÔ˶¯¾àÀë + Output: // + Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MEclipseHelicalAbsSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fcenter1, float fcenter2, int idirection, float fADis, float fBDis,float fDistance3); + + +/************************************************************* +Description: //¿Õ¼äÔ²»¡ + ÂÝÐý²å²¹Ô˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + end1 µÚ1¸öÖáÔ˶¯¾àÀë²ÎÊý1 Ïà¶ÔÓëÆðµã + end2 µÚ2¸öÖáÔ˶¯¾àÀë²ÎÊý1 Ïà¶ÔÓëÆðµã + end3 µÚ3¸öÖáÔ˶¯¾àÀë²ÎÊý1 Ïà¶ÔÓëÆðµã + centre1 µÚ1¸öÖáÔ˶¯¾àÀë²ÎÊý2 Ïà¶ÔÓëÆðµã + centre2 µÚ2¸öÖáÔ˶¯¾àÀë²ÎÊý2 Ïà¶ÔÓëÆðµã + centre3 µÚ3¸öÖáÔ˶¯¾àÀë²ÎÊý2 Ïà¶ÔÓëÆðµã + mode Ö¸¶¨Ç°Ãæ²ÎÊýµÄÒâÒå + 0 µ±Ç°µã£¬Öмäµã£¬ÖÕµãÈýµã¶¨Ô²»¡£¬¾àÀë²ÎÊý1ΪÖÕµã¾àÀ룬¾àÀë²ÎÊý2ΪÖмäµã¾àÀë¡£ + 1 ×ß×îСµÄÔ²»¡£¬¾àÀë²ÎÊý1ΪÖÕµã¾àÀ룬¾àÀë²ÎÊý2ΪԲÐĵľàÀë¡£ + 2 µ±Ç°µã£¬Öмäµã£¬ÖÕµãÈýµã¶¨Ô²£¬¾àÀë²ÎÊý1ΪÖÕµã¾àÀ룬¾àÀë²ÎÊý2ΪÖмäµã¾àÀë¡£ + 3 ÏÈ×ß×îСµÄÔ²»¡£¬ÔÙ¼ÌÐø×ßÍêÕûÔ²£¬¾àÀë²ÎÊý1ΪÖÕµã¾àÀ룬¾àÀë²ÎÊý2ΪԲÐĵľàÀë¡£ + fcenter4 µÚ4¸öÖáÔ˶¯¾àÀë²ÎÊý + fcenter5 µÚ5¸öÖáÔ˶¯¾àÀë²ÎÊý +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MSpherical(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fend3, float fcenter1, float fcenter2, float fcenter3, int imode, float fcenter4, float fcenter5); + + +/************************************************************* +Description: //¿Õ¼äÔ²»¡ + ÂÝÐý ²å²¹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + end1 µÚ1¸öÖáÔ˶¯¾àÀë²ÎÊý1 Ïà¶ÔÓëÆðµã + end2 µÚ2¸öÖáÔ˶¯¾àÀë²ÎÊý1 Ïà¶ÔÓëÆðµã + end3 µÚ3¸öÖáÔ˶¯¾àÀë²ÎÊý1 Ïà¶ÔÓëÆðµã + centre1 µÚ1¸öÖáÔ˶¯¾àÀë²ÎÊý2 Ïà¶ÔÓëÆðµã + centre2 µÚ2¸öÖáÔ˶¯¾àÀë²ÎÊý2 Ïà¶ÔÓëÆðµã + centre3 µÚ3¸öÖáÔ˶¯¾àÀë²ÎÊý2 Ïà¶ÔÓëÆðµã + mode Ö¸¶¨Ç°Ãæ²ÎÊýµÄÒâÒå + 0 µ±Ç°µã£¬Öмäµã£¬ÖÕµãÈýµã¶¨Ô²»¡£¬¾àÀë²ÎÊý1ΪÖÕµã¾àÀ룬¾àÀë²ÎÊý2ΪÖмäµã¾àÀë¡£ + 1 ×ß×îСµÄÔ²»¡£¬¾àÀë²ÎÊý1ΪÖÕµã¾àÀ룬¾àÀë²ÎÊý2ΪԲÐĵľàÀë¡£ + 2 µ±Ç°µã£¬Öмäµã£¬ÖÕµãÈýµã¶¨Ô²£¬¾àÀë²ÎÊý1ΪÖÕµã¾àÀ룬¾àÀë²ÎÊý2ΪÖмäµã¾àÀë¡£ + 3 ÏÈ×ß×îСµÄÔ²»¡£¬ÔÙ¼ÌÐø×ßÍêÕûÔ²£¬¾àÀë²ÎÊý1ΪÖÕµã¾àÀ룬¾àÀë²ÎÊý2ΪԲÐĵľàÀë¡£ + fcenter4 µÚ4¸öÖáÔ˶¯¾àÀë²ÎÊý + fcenter5 µÚ5¸öÖáÔ˶¯¾àÀë²ÎÊý +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MSphericalSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float fend1, float fend2, float fend3, float fcenter1, float fcenter2, float fcenter3, int imode, float fcenter4, float fcenter5); + + + +/************************************************************* +Description: //½¥¿ªÏßÔ²»¡²å²¹Ô˶¯£¬Ïà¶ÔÒÆ¶¯·½Ê½£¬µ±Æðʼ°ë¾¶0Ö±½ÓÀ©É¢Ê±´Ó0½Ç¶È¿ªÊ¼ +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + centre1: µÚ1ÖáÔ²ÐĵÄÏà¶Ô¾àÀë + centre2: µÚ2ÖáÔ²ÐĵÄÏà¶Ô¾àÀë + circles: ÒªÐýתµÄȦÊý£¬¿ÉÒÔΪСÊýȦ£¬¸ºÊý±íʾ˳ʱÕë. + pitch: ÿȦµÄÀ©É¢¾àÀ룬¿ÉÒÔΪ¸º¡£ + distance3 µÚ3ÖáÂÝÐýµÄ¹¦ÄÜ£¬Ö¸¶¨µÚ3ÖáµÄÏà¶Ô¾àÀ룬´ËÖá²»²ÎÓëËٶȼÆËã¡£ + distance4 µÚ4ÖáÂÝÐýµÄ¹¦ÄÜ£¬Ö¸¶¨µÚ4ÖáµÄÏà¶Ô¾àÀ룬´ËÖá²»²ÎÓëËٶȼÆËã¡£ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveSpiral(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float centre1, float centre2, float circles, float pitch, float distance3, float distance4); + +/************************************************************* +Description: //½¥¿ªÏßÔ²»¡²å²¹SPÔ˶¯£¬Ïà¶ÔÒÆ¶¯·½Ê½£¬µ±Æðʼ°ë¾¶0Ö±½ÓÀ©É¢Ê±´Ó0½Ç¶È¿ªÊ¼ +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + centre1: µÚ1ÖáÔ²ÐĵÄÏà¶Ô¾àÀë + centre2: µÚ2ÖáÔ²ÐĵÄÏà¶Ô¾àÀë + circles: ÒªÐýתµÄȦÊý£¬¿ÉÒÔΪСÊýȦ£¬¸ºÊý±íʾ˳ʱÕë. + pitch: ÿȦµÄÀ©É¢¾àÀ룬¿ÉÒÔΪ¸º¡£ + distance3 µÚ3ÖáÂÝÐýµÄ¹¦ÄÜ£¬Ö¸¶¨µÚ3ÖáµÄÏà¶Ô¾àÀ룬´ËÖá²»²ÎÓëËٶȼÆËã¡£ + distance4 µÚ4ÖáÂÝÐýµÄ¹¦ÄÜ£¬Ö¸¶¨µÚ4ÖáµÄÏà¶Ô¾àÀ룬´ËÖá²»²ÎÓëËٶȼÆËã¡£ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveSpiralSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float centre1, float centre2, float circles, float pitch, float distance3, float distance4); + +/************************************************************* +Description: //¿Õ¼äÖ±ÏßÔ˶¯£¬¸ù¾ÝÏÂÒ»¸öÖ±ÏßÔ˶¯µÄ¾ø¶Ô×ø±êÔÚ¹Õ½Ç×Ô¶¯²åÈëÔ²»¡£¬¼ÓÈëÔ²»¡ºó»áʹµÃÔ˶¯µÄÖÕµãÓëÖ±ÏßµÄÖյ㲻һÖ£¬¹Õ½Ç¹ý´óʱ²»»á²åÈëÔ²»¡£¬µ±¾àÀë²»¹»Ê±»á×Ô¶¯¼õС°ë¾¶ +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + end1 µÚ1¸öÖáÔ˶¯¾ø¶Ô×ø±ê + end2 µÚ2¸öÖáÔ˶¯¾ø¶Ô×ø±ê + end3 µÚ3¸öÖáÔ˶¯¾ø¶Ô×ø±ê + next1 µÚ1¸öÖáÏÂÒ»¸öÖ±ÏßÔ˶¯¾ø¶Ô×ø±ê + next2 µÚ2¸öÖáÏÂÒ»¸öÖ±ÏßÔ˶¯¾ø¶Ô×ø±ê + next3 µÚ3¸öÖáÏÂÒ»¸öÖ±ÏßÔ˶¯¾ø¶Ô×ø±ê + radius ²åÈëÔ²»¡µÄ°ë¾¶£¬µ±¹ý´óµÄʱºò×Ô¶¯ËõС¡£ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveSmooth(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float end1, float end2, float end3, float next1, float next2, float next3, float radius); + +/************************************************************* +Description: //¿Õ¼äÖ±Ï߲岹SPÔ˶¯£¬¸ù¾ÝÏÂÒ»¸öÖ±ÏßÔ˶¯µÄ¾ø¶Ô×ø±êÔÚ¹Õ½Ç×Ô¶¯²åÈëÔ²»¡£¬¼ÓÈëÔ²»¡ºó»áʹµÃÔ˶¯µÄÖÕµãÓëÖ±ÏßµÄÖյ㲻һÖ£¬¹Õ½Ç¹ý´óʱ²»»á²åÈëÔ²»¡£¬µ±¾àÀë²»¹»Ê±»á×Ô¶¯¼õС°ë¾¶ +Input: //¿¨Á´½Óhandle + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + end1 µÚ1¸öÖáÔ˶¯¾ø¶Ô×ø±ê + end2 µÚ2¸öÖáÔ˶¯¾ø¶Ô×ø±ê + end3 µÚ3¸öÖáÔ˶¯¾ø¶Ô×ø±ê + next1 µÚ1¸öÖáÏÂÒ»¸öÖ±ÏßÔ˶¯¾ø¶Ô×ø±ê + next2 µÚ2¸öÖáÏÂÒ»¸öÖ±ÏßÔ˶¯¾ø¶Ô×ø±ê + next3 µÚ3¸öÖáÏÂÒ»¸öÖ±ÏßÔ˶¯¾ø¶Ô×ø±ê + radius ²åÈëÔ²»¡µÄ°ë¾¶£¬µ±¹ý´óµÄʱºò×Ô¶¯ËõС¡£ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveSmoothSp(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, float end1, float end2, float end3, float next1, float next2, float next3, float radius); + + +/************************************************************* +Description: //Ô˶¯ÔÝÍ£ £¬²å²¹Ô˶¯ÔÝÍ£Ö÷Öá¡£ÖáÁбíÖáµÚÒ»¸öÖá +Input: //¿¨Á´½Óhandle + ÖáºÅ iaxis + ģʽ imode 0£¨È±Ê¡£© ÔÝÍ£µ±Ç°Ô˶¯¡£ + 1 ÔÚµ±Ç°Ô˶¯Íê³ÉºóÕý×¼±¸Ö´ÐÐÏÂÒ»ÌõÔ˶¯Ö¸ÁîʱÔÝÍ£¡£ + 2 ÔÚµ±Ç°Ô˶¯Íê³ÉºóÕý×¼±¸Ö´ÐÐÏÂÒ»ÌõÔ˶¯Ö¸Áîʱ£¬²¢ÇÒÁ½ÌõÖ¸ÁîµÄMARK±êʶ²»Ò»ÑùʱÔÝÍ£¡£Õâ¸öģʽ¿ÉÒÔÓÃÓÚÒ»¸ö¶¯×÷Óɶà¸öÖ¸ÁîÀ´ÊµÏÖʱ£¬¿ÉÒÔÔÚÒ»Õû¸ö¶¯×÷Íê³ÉºóÔÝÍ£¡£ + +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MovePause(ZMC_HANDLE handle,int iaxis, int imode); + + +/************************************************************* +Description: //È¡ÏûÔ˶¯ÔÝÍ£ +Input: //¿¨Á´½Óhandle + ÖáºÅ iaxis +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveResume(ZMC_HANDLE handle,int iaxis); + +/************************************************************* +Description: //ÔÚµ±Ç°µÄÔ˶¯Ä©Î²Î»ÖÃÔö¼ÓËÙ¶ÈÏÞÖÆ£¬ÓÃÓÚÇ¿ÖÆ¹Õ½Ç¼õËÙ +Input: //¿¨Á´½Óhandle + ÖáºÅ iaxis +Output: // + Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveLimit(ZMC_HANDLE handle, int iaxis,float limitspeed); + +/************************************************************* +Description: //ÔÚÔ˶¯»º³åÖмÓÈëÊä³öÖ¸Áî +Input: //¿¨Á´½Óhandle + ÖáºÅ iaxis + Êä³ö¿Ú±àºÅ ioutnum + Êä³ö¿Ú״̬ ivalue +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveOp(ZMC_HANDLE handle, int iaxis,int ioutnum, int ivalue); + +/************************************************************* +Description: //ÔÚÔ˶¯»º³åÖмÓÈëÁ¬ÐøÊä³ö¿ÚÊä³öÖ¸Áî +Input: //¿¨Á´½Óhandle + ÖáºÅ iaxis + Êä³ö¿ÚÆðʼ±àºÅ ioutnumfirst + Êä³ö¿Ú½áÊø±àºÅ ioutnumend + ¶ÔÓ¦Êä³ö¿Ú״̬¶þ½øÖÆ×éºÏÖµ ivalue +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveOpMulti(ZMC_HANDLE handle, int iaxis, int ioutnumfirst, int ioutnumend, int ivalue); + +/************************************************************* +Description: //ÔÚÔ˶¯»º³åÖмÓÈëÊä³öÖ¸Áî ,Ö¸¶¨Ê±¼äºóÊä³ö״̬·­×ª +Input: //¿¨Á´½Óhandle + ÖáºÅ iaxis + Êä³ö¿Ú±àºÅ ioutnum + Êä³ö¿Ú״̬ ivalue + ״̬·´×ªÊ±¼ä iofftimems +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveOp2(ZMC_HANDLE handle, int iaxis, int ioutnum, int ivalue, int iofftimems); + +/************************************************************* +Description: //ÔÚÔ˶¯»º³åÖмÓÈëAOUTÊä³öÖ¸Áî +Input: //¿¨Á´½Óhandle + ÖáºÅ iaxis + DA¿Ú±àºÅ ioutnum + Ä£ÄâÁ¿Öµ fvalue £¨4ϵÁÐÒÔÏ 12λ0-4095£© +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveAout(ZMC_HANDLE handle, int iaxis, int ioutnum, float fvalue); + +/************************************************************* +Description: //ÔÚÔ˶¯»º³åÖмÓÈëÑÓʱָÁî +Input: //¿¨Á´½Óhandle + ÖáºÅ iaxis + ÑÓʱʱ¼ä itimems ºÁÃë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveDelay(ZMC_HANDLE handle, int iaxis, int itimems); + +/************************************************************* +Description: //Ðýתֱ̨Ï߲岹Ô˶¯¡£ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + tablenum ´æ´¢Ðýת̨²ÎÊýµÄtable±àºÅ + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + pfDisancelist ¾àÀëÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveTurnabs(ZMC_HANDLE handle, int tablenum ,int imaxaxises,int *piAxislist, float *pfDisancelist); + +/************************************************************* +Description: //Ðýת̨Բ»¡+ÂÝÐý²å²¹Ô˶¯¡£ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle + tablenum ´æ´¢Ðýת²ÎÊýµÄtable±àºÅ + refpos1 µÚÒ»¸öÖá²Î¿¼µã£¬¾ø¶ÔλÖà + refpos2 µÚ¶þ¸öÖá²Î¿¼µã£¬¾ø¶ÔλÖà + mode 1-²Î¿¼µãÊǵ±Ç°µãÇ°Ãæ£¬2-²Î¿¼µãÊǽáÊøµãºóÃæ£¬3-²Î¿¼µãÔÚÖм䣬²ÉÓÃÈýµã¶¨Ô²µÄ·½Ê½¡£ + end1 µÚÒ»¸öÖá½áÊøµã£¬¾ø¶ÔλÖà + end2 µÚ¶þ¸öÖá½áÊøµã£¬¾ø¶ÔλÖà + imaxaxises ²ÎÓëÔ˶¯ÖáÊýÁ¿ + piAxislist ÖáÁбí + pfDisancelistNumber ÂÝÐýÖá¾àÀëÓÐЧµÄÊýÁ¿ ÈçÊýÁ¿Îª2±íʾµÚ0¸öºÍµÚ2¸öÖáÐèÒªÓÐЧµÄ½áÊøÎ»Ö㬵«µÚÈý¸öÖá²»²ÎÓë + pfDisancelist ÂÝÐýÖá¾àÀëÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_McircTurnabs(ZMC_HANDLE handle, int tablenum ,float refpos1,float refpos2,int mode,float end1,float end2,int imaxaxises,int *piAxislist , uint pfDisancelistNumber,float *pfDisancelist); + +/************************************************************* +Description: //µç×Ó͹ÂÖ Í¬²½Ô˶¯ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + istartpoint ÆðʼµãTABLE±àºÅ + iendpoint ½áÊøµãTABLE±àºÅ + ftablemulti λÖñÈÀý£¬Ò»°ãÉèΪÂö³åµ±Á¿Öµ + fDistance ²Î¿¼Ô˶¯µÄ¾àÀ룬ÓÃÀ´¼ÆËã×ÜÔ˶¯Ê±¼ä + Output: // + Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Cam(ZMC_HANDLE handle,int iaxis, int istartpoint, int iendpoint, float ftablemulti, float fDistance); + +/************************************************************* +Description: //µç×Ó͹ÂÖ Í¬²½Ô˶¯ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + istartpoint ÆðʼµãTABLE±àºÅ + iendpoint ½áÊøµãTABLE±àºÅ + ftablemulti λÖñÈÀý£¬Ò»°ãÉèΪÂö³åµ±Á¿Öµ + fDistance ²Î¿¼Ô˶¯µÄ¾àÀë + ilinkaxis ²Î¿¼Ö÷Öá + ioption ²Î¿¼ÖáµÄÁ¬½Ó·½Ê½,¾ßÌåÒâÒåÇë²Î¼ûZBASIC±à³ÌÊÖ²á»òPCº¯ÊýÊÖ²á + flinkstartpos µ±ioption²ÎÊýÉèÖÃΪ2ʱ£¬¸Ã²ÎÊý±íʾÁ¬½Ó¿ªÊ¼Æô¶¯µÄ¾ø¶ÔλÖà + linkoffpos: µ±ioption²ÎÊýbit4ÖÃΪ1ʱ£¬¸Ã²ÎÊý±íʾÖ÷ÖáÒѾ­ÔËÐÐÍêµÄÏà¶ÔλÖà + Output: // + Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Cambox(ZMC_HANDLE handle, int iaxis,int istartpoint, int iendpoint, float ftablemulti, float fDistance, int ilinkaxis, int ioption, float flinkstartpos,float linkoffpos); + + + +/************************************************************* +Description: //ÌØÊâ͹ÂÖ Í¬²½Ô˶¯ +Input: //¿¨Á´½Óhandle + iaxis ²ÎÓëÔ˶¯µÄÖáºÅ(¸úËæÖá) + fDistance ͬ²½¹ý³Ì¸úËæÖáÔ˶¯¾àÀë + fLinkDis ͬ²½¹ý³Ì²Î¿¼Öá(Ö÷Öá)Ô˶¯¾ø¶Ô¾àÀë + fLinkAcc ¸úËæÖá¼ÓËٽ׶Σ¬²Î¿¼ÖáÒÆ¶¯µÄ¾ø¶Ô¾àÀë + fLinkDec ¸úËæÖá¼õËٽ׶Σ¬²Î¿¼ÖáÒÆ¶¯µÄ¾ø¶Ô¾àÀë + iLinkaxis ²Î¿¼ÖáµÄÖáºÅ + ioption Á¬½ÓģʽѡÏî,¾ßÌåÒâÒåÇë²Î¼ûZBASIC±à³ÌÊÖ²á»òPCº¯ÊýÊÖ²á + link_pos µ±ioption²ÎÊýÉèÖÃΪ2ʱ£¬¸Ã²ÎÊý±íʾÁ¬½Ó¿ªÊ¼Æô¶¯µÄ¾ø¶ÔλÖà + link_offpos µ±ioption²ÎÊýbit4ÖÃΪ1ʱ£¬¸Ã²ÎÊý±íʾÖ÷ÖáÒѾ­ÔËÐÐÍêµÄÏà¶ÔλÖà +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Movelink(ZMC_HANDLE handle, int iaxis, float fDistance, float fLinkDis, float fLinkAcc, float fLinkDec, int iLinkaxis, int ioption, float link_pos, float link_offpos); + +/************************************************************* +Description: //ÌØÊâ͹ÂÖ Í¬²½Ô˶¯ +Input: //¿¨Á´½Óhandle + iaxis ²ÎÓëÔ˶¯µÄÖáºÅ(¸úËæÖá) + fDistance ͬ²½¹ý³Ì¸úËæÖáÔ˶¯¾àÀë + fLinkDis ͬ²½¹ý³Ì²Î¿¼Öá(Ö÷Öá)Ô˶¯¾ø¶Ô¾àÀë + startsp Æô¶¯Ê±¸úËæÖáºÍ²Î¿¼ÖáµÄËٶȱÈÀý£¬units/unitsµ¥Î»£¬¸ºÊý±íʾ¸úËæÖḺÏòÔ˶¯ + endsp ½áÊøÊ±¸úËæÖáºÍ²Î¿¼ÖáµÄËٶȱÈÀý£¬units/unitsµ¥Î», ¸ºÊý±íʾ¸úËæÖḺÏòÔ˶¯¡£ + iLinkaxis ²Î¿¼ÖáµÄÖáºÅ + ioption Á¬½ÓģʽѡÏî,¾ßÌåÒâÒåÇë²Î¼ûZBASIC±à³ÌÊÖ²á»òPCº¯ÊýÊÖ²á + link_pos µ±ioption²ÎÊýÉèÖÃΪ2ʱ£¬¸Ã²ÎÊý±íʾ²Î¿¼ÖáÔڸþø¶ÔλÖÃֵʱ£¬Á¬½Ó¿ªÊ¼ + link_offpos µ±ioption²ÎÊýbit4ÖÃΪ1ʱ£¬¸Ã²ÎÊý±íʾÖ÷ÖáÒѾ­ÔËÐÐÍêµÄÏà¶ÔλÖá£20170428ÒÔÉϹ̼þÖ§³Ö¡£ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Moveslink(ZMC_HANDLE handle, int iaxis,float fDistance, float fLinkDis, float startsp, float endsp,int iLinkaxis, int ioption,float link_pos,float link_offpos); + +/************************************************************* +Description: //Á¬½Ó ͬ²½Ô˶¯Ö¸Áî µç×Ó³ÝÂÖ +Input: //¿¨Á´½Óhandle + ratio ±ÈÂÊ£¬¿ÉÕý¿É¸º£¬×¢ÒâÊÇÂö³å¸öÊýµÄ±ÈÀý¡£ + link_axis Á¬½ÓÖáµÄÖáºÅ£¬ÊÖÂÖʱΪ±àÂëÆ÷Öá + move_axis Ëæ¶¯ÖáºÅ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Connect(ZMC_HANDLE handle, float ratio, int link_axis, int move_axis); + +/************************************************************* +Description: //Á¬½Ó ͬ²½Ô˶¯Ö¸Áî µç×Ó³ÝÂÖ ½«µ±Ç°ÖáµÄÄ¿±êλÖÃÓëlink_axisÖáµÄ²å²¹Ê¸Á¿³¤¶Èͨ¹ýµç×Ó³ÝÂÖÁ¬½Ó +Input: //¿¨Á´½Óhandle + ratio ±ÈÂÊ£¬¿ÉÕý¿É¸º£¬×¢ÒâÊÇÂö³å¸öÊýµÄ±ÈÀý¡£ + link_axis Á¬½ÓÖáµÄÖáºÅ£¬ÊÖÂÖʱΪ±àÂëÆ÷Öá + move_axis Ëæ¶¯ÖáºÅ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Connpath(ZMC_HANDLE handle, float ratio, int link_axis, int move_axis); + + +/************************************************************* +Description: //λÖÃËø´æÖ¸Áî +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + imode Ëø´æÄ£Ê½ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Regist(ZMC_HANDLE handle,int iaxis, int imode); + +/************************************************************* +Description: //±àÂëÆ÷ÊäÈë³ÝÂֱȣ¬È±Ê¡(1,1) +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + mpos_count ·Ö×Ó£¬²»Òª³¬¹ý65535 + input_count ·Öĸ£¬²»Òª³¬¹ý65535 +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_EncoderRatio(ZMC_HANDLE handle, int iaxis,int output_count,int input_count); + +/************************************************************* +Description: //ÉèÖò½½øÊä³ö³ÝÂֱȣ¬È±Ê¡(1,1) +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + mpos_count ·Ö×Ó£¬1-65535 + input_count ·Öĸ£¬1-65535 +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_StepRatio(ZMC_HANDLE handle, int iaxis, int output_count,int input_count); + + +/************************************************************* +Description: //ËùÓÐÖáÁ¢¼´Í£Ö¹ +Input: //¿¨Á´½Óhandle + imode ֹͣģʽ + 0£¨È±Ê¡£©È¡Ïûµ±Ç°Ô˶¯ + 1 È¡Ïû»º³åµÄÔ˶¯ + 2 È¡Ïûµ±Ç°Ô˶¯ºÍ»º³åÔ˶¯¡£ + 3 Á¢¼´ÖжÏÂö³å·¢ËÍ¡£ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Rapidstop(ZMC_HANDLE handle, int imode); + +/************************************************************* +Description: //¶à¸öÖáÔ˶¯Í£Ö¹ +Input: //¿¨Á´½Óhandle ÖáºÅ£¬ ¾àÀë + imaxaxises ÖáÊý + piAxislist ÖáÁбí + imode ģʽ + 0£¨È±Ê¡£©È¡Ïûµ±Ç°Ô˶¯ + 1 È¡Ïû»º³åµÄÔ˶¯ + 2 È¡Ïûµ±Ç°Ô˶¯ºÍ»º³åÔ˶¯¡£ + 3 Á¢¼´ÖжÏÂö³å·¢ËÍ¡£ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_CancelAxisList(ZMC_HANDLE handle, int imaxaxises, int *piAxislist, int imode); + +/************************************************************* +Description: //CONNFRAME»úеÊÖÄæ½âÖ¸Áî 2ϵÁÐÒÔÉÏ¿ØÖÆÆ÷Ö§³Ö +Input: //¿¨Á´½Óhandle + Jogmaxaxises ¹Ø½ÚÖáÊýÁ¿ + JogAxislist ¹Ø½ÚÖáÁбí + frame »úеÊÖÀàÐÍ + tablenum »úеÊÖ²ÎÊýTABLEÆðʼ±àºÅ + Virmaxaxises ¹ØÁªÐéÄâÖá¸öÊý + VirAxislist ÐéÄâÖáÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Connframe(ZMC_HANDLE handle,int Jogmaxaxises,int *JogAxislist, int frame, int tablenum , int Virmaxaxises , int *VirAxislist); + +/************************************************************* +Description: //CONNREFRAME»úеÊÖÕý½âÖ¸Áî 2ϵÁÐÒÔÉÏ¿ØÖÆÆ÷Ö§³Ö +Input: //¿¨Á´½Óhandle + Virmaxaxises ¹ØÁªÐéÄâÖá¸öÊý + VirAxislist ÐéÄâÖáÁбí + frame »úеÊÖÀàÐÍ + tablenum »úеÊÖ²ÎÊýTABLEÆðʼ±àºÅ + Jogmaxaxises ¹Ø½ÚÖáÊýÁ¿ + JogAxislist ¹Ø½ÚÖáÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Connreframe(ZMC_HANDLE handle,int Virmaxaxises , int *VirAxislist, int frame, int tablenum , int Jogmaxaxises,int *JogAxislist); + + +#if 0 + //µ¥ÖắÊý +#endif + +/************************************************************* +Description: //Öáµþ¼ÓÔ˶¯ iaddaxisÔ˶¯µþ¼Óµ½iaxisÖá £¬ADDAXÖ¸Áîµþ¼ÓµÄÊÇÂö³å¸öÊý +Input: //¿¨Á´½Óhandle + iaxis ±»µþ¼ÓÖá + iaddaxis µþ¼ÓÖá +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Single_Addax(ZMC_HANDLE handle, int iaxis, int iaddaxis); + +/************************************************************* +Description: //µ¥ÖáÔ˶¯Í£Ö¹ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + imode ģʽ + 0£¨È±Ê¡£©È¡Ïûµ±Ç°Ô˶¯ + 1 È¡Ïû»º³åµÄÔ˶¯ + 2 È¡Ïûµ±Ç°Ô˶¯ºÍ»º³åÔ˶¯¡£ + 3 Á¢¼´ÖжÏÂö³å·¢ËÍ¡£ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Single_Cancel(ZMC_HANDLE handle, int iaxis, int imode); + + +/************************************************************* +Description: //µ¥ÖáÁ¬ÐøÔ˶¯ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + idir ·½Ïò 1ÕýÏò -1¸ºÏò +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Single_Vmove(ZMC_HANDLE handle, int iaxis, int idir); + +/************************************************************* +Description: //¿ØÖÆÆ÷·½Ê½»ØÁã +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + imode ģʽ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Single_Datum(ZMC_HANDLE handle, int iaxis, int imode); + +/************************************************************* +Description: //»ØÁãÍê³É״̬ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //homestatus »ØÁãÍê³É±êÖ¾ 0-»ØÁãÒì³£ 1»ØÁã³É¹¦ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetHomeStatus(ZMC_HANDLE handle,uint32 iaxis,uint32 * homestatus); + + +/************************************************************* +Description: //µ¥ÖáÏà¶ÔÔ˶¯ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fdistance ¾àÀë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Single_Move(ZMC_HANDLE handle, int iaxis, float fdistance); + + +/************************************************************* +Description: //µ¥Öá¾ø¶ÔÔ˶¯ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fdistance ¾àÀë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Single_MoveAbs(ZMC_HANDLE handle, int iaxis, float fdistance); + + +#if 0 + //¸¨Öúº¯Êý +#endif + +/*********************ÄÚ´æ²Ù×÷ + +/*********************ÄÚ´æ²Ù×÷ +/************************************************************* +Description: //дVR, +Input: //¿¨Á´½Óhandle + vrstartnum VRÆðʼ±àºÅ + numes дÈëµÄÊýÁ¿ + pfValue дÈëµÄÊý¾ÝÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetVrf(ZMC_HANDLE handle,int vrstartnum, int numes, float *pfValue); + +/************************************************************* +Description: //VR¶ÁÈ¡, ¿ÉÒÔÒ»´Î¶ÁÈ¡¶à¸ö +Input: //¿¨Á´½Óhandle + vrstartnum ¶ÁÈ¡µÄVRÆðʼµØÖ· + numes ¶ÁÈ¡µÄÊýÁ¿ +Output: //pfValue ·µ»ØµÄ¶Áȡֵ£¬¶à¸öʱ±ØÐë·ÖÅä¿Õ¼ä. +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetVrf(ZMC_HANDLE handle, int vrstartnum, int numes, float *pfValue); + + +/************************************************************* +Description: //VRINT¶ÁÈ¡£¬ ±ØÐë150401ÒÔÉϰ汾²ÅÖ§³ÖVRINTµÄDIRECTCOMMAND¶ÁÈ¡ +Input: //¿¨Á´½Óhandle + vrstartnum ¶ÁÈ¡µÄVRÆðʼµØÖ· + numes ¶ÁÈ¡µÄÊýÁ¿ + Output: //piValue ·µ»ØµÄ¶Áȡֵ£¬¶à¸öʱ±ØÐë·ÖÅä¿Õ¼ä. +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetVrInt(ZMC_HANDLE handle, int vrstartnum, int numes, int *piValue); + +/************************************************************* +Description: //дtable +Input: //¿¨Á´½Óhandle + tabstart дÈëµÄTABLEÆðʼ±àºÅ + numes дÈëµÄÊýÁ¿ + pfValue дÈëµÄÊý¾ÝÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetTable(ZMC_HANDLE handle,int tabstart, int numes, float *pfValue); + +/************************************************************* +Description: //table¶ÁÈ¡, ¿ÉÒÔÒ»´Î¶ÁÈ¡¶à¸ö +Input: //¿¨Á´½Óhandle + tabstart ¶ÁÈ¡TABLEÆðʼµØÖ· + numes ¶ÁÈ¡µÄÊýÁ¿ +Output: //pfValue ¶à¸öʱ±ØÐë·ÖÅä¿Õ¼ä. +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetTable(ZMC_HANDLE handle, int tabstart, int numes, float *pfValue); + +/************************************************************* +Description: //×Ö·û´®×ªÎªfloat +Input: //¿¨Á´½Óhandle + pstringin Êý¾ÝµÄ×Ö·û´® + inumes ת»»Êý¾Ý¸öÊý +Output: //pfvlaue ת»»µÄÊý¾Ý +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_TransStringtoFloat(const char* pstringin, int inumes, float* pfvlaue); + + +/************************************************************* +Description: //×Ö·û´®×ªÎªint +Input: //¿¨Á´½Óhandle + pstringin Êý¾ÝµÄ×Ö·û´® + inumes ת»»Êý¾Ý¸öÊý +Output: //pivlaue ת»»µÄÊý¾Ý +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_TransStringtoInt(const char* pstringin, int inumes, int* pivlaue); + +/************************************************************* +Description: //°Ñfloat¸ñʽµÄ±äÁ¿ÁÐ±í´æ´¢µ½Îļþ£¬ Óë¿ØÖÆÆ÷µÄUÅÌÎļþ¸ñʽһÖÂ. +Input: //sFilename Îļþ¾ø¶Ô·¾¶ + pVarlist дÈëµÄÊý¾ÝÁбí + inum Êý¾ÝµÄ³¤¶È +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_WriteUFile(const char *sFilename, float *pVarlist, int inum); + +/************************************************************* +Description: //¶ÁÈ¡float¸ñʽµÄ±äÁ¿ÁÐ±í£¬ Óë¿ØÖÆÆ÷µÄUÅÌÎļþ¸ñʽһÖÂ. +Input: //sFilename Îļþ¾ø¶Ô·¾¶ + inum Êý¾ÝµÄ³¤¶È +Output: //pVarlist ¶ÁÈ¡µÄÊý¾ÝÁбí +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_ReadUFile(const char *sFilename, float *pVarlist, int* pinum); + + +/************************************************************* +Description: //modbus¼Ä´æÆ÷²Ù×÷ modbus_bit +Input: //¿¨Á´½Óhandle ¼Ä´æÆ÷µØÖ· + start Æðʼ±àºÅ + inum ÊýÁ¿ + pdata ÉèÖõÄλ״̬ °´Î»´æ´¢ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Modbus_Set0x(ZMC_HANDLE handle, uint16 start, uint16 inum, uint8* pdata); + +/************************************************************* +Description: //modbus¼Ä´æÆ÷²Ù×÷ modbus_bit +Input: //¿¨Á´½Óhandle ¼Ä´æÆ÷µØÖ· + start Æðʼ±àºÅ + inum ÊýÁ¿ +Output: //pdata ·µ»ØµÄλ״̬ °´Î»´æ´¢ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Modbus_Get0x(ZMC_HANDLE handle, uint16 start, uint16 inum, uint8* pdata); + +/************************************************************* +Description: //modbus¼Ä´æÆ÷²Ù×÷ MODBUS_REG +Input: //¿¨Á´½Óhandle ¼Ä´æÆ÷µØÖ· + start Æðʼ±àºÅ + inum ÊýÁ¿ +Output: //pdata ¶ÁÈ¡µÄREG¼Ä´æÆ÷Öµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Modbus_Set4x(ZMC_HANDLE handle, uint16 start, uint16 inum, uint16* pdata); + +/************************************************************* +Description: //modbus¼Ä´æÆ÷²Ù×÷ MODBUS_REG +Input: //¿¨Á´½Óhandle ¼Ä´æÆ÷µØÖ· + start Æðʼ±àºÅ + inum ÊýÁ¿ +Output: //pdata ¶ÁÈ¡µÄREG¼Ä´æÆ÷Öµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Modbus_Get4x(ZMC_HANDLE handle, uint16 start, uint16 inum, uint16* pdata); + +/************************************************************* +Description: //modbus¼Ä´æÆ÷²Ù×÷ MODBUS_IEEE +Input: //¿¨Á´½Óhandle ¼Ä´æÆ÷µØÖ· + start Æðʼ±àºÅ + inum ÊýÁ¿ + pfdata ÉèÖÃÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Modbus_Get4x_Float(ZMC_HANDLE handle, uint16 start, uint16 inum, float* pfdata); + +/************************************************************* +Description: //modbus¼Ä´æÆ÷²Ù×÷ MODBUS_IEEE +Input: //¿¨Á´½Óhandle ¼Ä´æÆ÷µØÖ· + start Æðʼ±àºÅ + inum ÊýÁ¿ + pfdata ÉèÖÃÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Modbus_Set4x_Float(ZMC_HANDLE handle, uint16 start, uint16 inum, float* pfdata); + +/************************************************************* +Description: //modbus¼Ä´æÆ÷²Ù×÷ MODBUS_LONG +Input: //¿¨Á´½Óhandle ¼Ä´æÆ÷µØÖ· + start Æðʼ±àºÅ + inum ÊýÁ¿ + pidata ÉèÖÃÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Modbus_Get4x_Long(ZMC_HANDLE handle, uint16 start, uint16 inum, int32* pidata); + +/************************************************************* +Description: //modbus¼Ä´æÆ÷²Ù×÷ MODBUS_LONG +Input: //¿¨Á´½Óhandle ¼Ä´æÆ÷µØÖ· + start Æðʼ±àºÅ + inum ÊýÁ¿ +Output: //pidata ¶ÁÈ¡µÄREG¼Ä´æÆ÷Öµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Modbus_Set4x_Long(ZMC_HANDLE handle, uint16 start, uint16 inum, int32* pidata); + +/************************************************************* +Description: //¶ÁÈ¡modbus_string +Input: //¿¨Á´½Óhandle + start modbusÆðʼµØÖ· + inum ³¤¶È +Output: pidata ¶ÁÈ¡·µ»ØµÄ×Ö·û´® +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Modbus_Get4x_String(ZMC_HANDLE handle, uint16 start, uint16 inum, char * pidata); + +/************************************************************* +Description: //ÉèÖÃmodbus_string +Input: //¿¨Á´½Óhandle + start modbusÆðʼµØÖ· + inum ³¤¶È + pidata дÈëµÄ×Ö·û´® +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Modbus_Set4x_String(ZMC_HANDLE handle, uint16 start, uint16 inum, char * pidata); + +/************************************************************* +Description: //дÓû§flash¿é, floatÊý¾Ý +Input: //¿¨Á´½Óhandle + uiflashid flash¿éºÅ + uinumes ±äÁ¿¸öÊý +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_FlashWritef(ZMC_HANDLE handle, uint16 uiflashid, uint32 uinumes, float *pfvlue); + +/************************************************************* +Description: //¶ÁÈ¡Óû§flash¿é, floatÊý¾Ý +Input: //¿¨Á´½Óhandle + uiflashid flash¿éºÅ + uibuffnum »º³å±äÁ¿¸öÊý +Output: // + puinumesread ¶ÁÈ¡µ½µÄ±äÁ¿¸öÊý +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_FlashReadf(ZMC_HANDLE handle, uint16 uiflashid, uint32 uibuffnum, float *pfvlue, uint32* puinumesread); + + + +/*****************************************************************************************************2018-08-24 V2.1º¯ÊýÌí¼Ó************************** +/************************************************************* +Description: //ʾ²¨Æ÷´¥·¢º¯Êý 150723ÒÔºó¹Ì¼þ°æ±¾Ö§³Ö +Input: //¿¨Á´½Óhandle +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Trigger(ZMC_HANDLE handle); + + +//MOVE_PARA,MOVE_PWM, MOVE_SYNMOVE,MOVE_ASYNMOVE +/************************************************************* +Description: //Ô˶¯ÖÐÐ޸IJÎÊý. 20170503ÒÔÉϹ̼þÖ§³Ö +Input: //¿¨Á´½Óhandle PARANAME, INDEX, VALUE +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MovePara(ZMC_HANDLE handle,uint32 base_axis,char * paraname,uint32 iaxis,float fvalue); + +/************************************************************* +Description: //Ô˶¯ÖÐÐÞ¸ÄPWM 20170503ÒÔÉϹ̼þÖ§³Ö +Input: //¿¨Á´½Óhandle PARANAME, INDEX, VALUE +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MovePwm(ZMC_HANDLE handle,uint32 base_axis,uint32 pwm_num,float pwm_duty,float pwm_freq); + +/************************************************************* +Description: //Ô˶¯ÖÐͬ²½ÆäËûÖáµÄÔ˶¯,. 20170503ÒÔÉϹ̼þÖ§³Ö +Input: //¿¨Á´½Óhandle PARANAME, INDEX, VALUE +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveSynmove(ZMC_HANDLE handle,uint32 base_axis,uint32 iaxis,float fdist,uint32 ifsp); + +/************************************************************* +Description: //Ô˶¯Öд¥·¢ÆäËûÖáµÄÔ˶¯. 20170503ÒÔÉϹ̼þÖ§³Ö +Input: //¿¨Á´½Óhandle PARANAME, INDEX, VALUE +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveASynmove(ZMC_HANDLE handle,uint32 base_axis,uint32 iaxis,float fdist,uint32 ifsp); + +/************************************************************* +Description: //Ô˶¯ÖÐÐÞ¸ÄTABLE +Input: //¿¨Á´½Óhandle + base_axis ²å²¹Ö÷Öá±àºÅ + table_num TABLE±àºÅ + fvalue ÐÞ¸ÄÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveTable(ZMC_HANDLE handle,uint32 base_axis,uint32 table_num,float fvalue); + + +/************************************************************* +Description: //BASEÖáÔ˶¯»º³å¼ÓÈëÒ»¸ö¿É±äµÄÑÓʱ ¹Ì¼þ150802ÒÔÉϰ汾£¬ »òXPLC160405ÒÔÉϰ汾֧³Ö¡£ +Input: //¿¨Á´½Óhandle + base_axis ²å²¹Ö÷Öá±àºÅ + paraname ²ÎÊýÃû×Ö·û´® DPOS MPOS IN AIN VPSPEED MSPEED MODBUS_REG MODBUS_IEEE MODBUS_BIT NVRAM VECT_BUFFED REMAIN + inum ²ÎÊý±àºÅ»òÖáºÅ + Cmp_mode ±È½ÏÌõ¼þ 1 >= 0= -1<= ¶ÔINµÈBITÀàÐͲÎÊýÎÞЧ¡£ + fvalue ±È½ÏÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveWait(ZMC_HANDLE handle,uint32 base_axis,char * paraname,int inum,int Cmp_mode,float fvalue); + +/************************************************************* +Description: //BASEÖáÔ˶¯»º³å¼ÓÈëÒ»¸öTASKÈÎÎñ µ±ÈÎÎñÒѾ­Æô¶¯Ê±£¬»á±¨´í£¬µ«²»Ó°Ïì³ÌÐòÖ´ÐС£ +Input: //¿¨Á´½Óhandle + base_axis ²å²¹Ö÷Öá±àºÅ + tasknum ÈÎÎñ±àºÅ + labelname BASÖÐÈ«¾Öº¯ÊýÃû»òÕß±êºÅ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveTask(ZMC_HANDLE handle,uint32 base_axis,uint32 tasknum,char * labelname); + +/************************************************************* +Description: //λÖñȽÏPSWITCH +Input: //¿¨Á´½Óhandle + //±È½ÏÆ÷±àºÅ num 0-15 + //±È½ÏÆ÷ʹÄÜ enable 0/1 + //±È½ÏµÄÖáºÅ axisnum + //Êä³ö¿Ú±àºÅ outnum + //Êä³ö״̬ outstate 0/1 + //±È½ÏÆðʼλÖà setpos + //Êä³ö¸´Î»Î»Öà resetpos +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Pswitch(ZMC_HANDLE handle, int num, int enable, int axisnum, int outnum, int outstate, float setpos,float resetpos); + +/************************************************************* +Description: //Ó²¼þλÖñȽÏÊä³ö 4ϵÁвúÆ·Âö³åÖáÓë±àÂëÆ÷ÖáÖ§³ÖÓ²¼þ±È½ÏÊä³ö +Input: //¿¨Á´½Óhandle + //ģʽ mode 1-Æô¶¯±È½ÏÆ÷, 2- Í£Ö¹²¢É¾³ýûÍê³ÉµÄ±È½Ïµã. + //·½Ïò direction 0-×ø±ê¸ºÏò, 1- ×ø±êÕýÏò + //Ô¤Áô Reserve Ô¤Áô + //TABLEÆðʼµã Tablestart µÚÒ»¸ö±È½Ïµã×ø±êËùÔÚTABLE±àºÅ + //TABLE½áÊøµã tableend ×îºóÒ»¸ö±È½Ïµã×ø±êËùÔÚTABLE±àºÅ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_HwPswitch(ZMC_HANDLE handle,int Axisnum,int Mode, int Direction, int Reserve, int Tablestart, int Tableend); + +/************************************************************* +Description: //Ó²¼þλÖñȽÏÊä³öÊ£Ó໺³å»ñÈ¡ 4ϵÁвúÆ·Âö³åÖáÓë±àÂëÆ÷ÖáÖ§³ÖÓ²¼þ±È½ÏÊä³ö +Input: //¿¨Á´½Óhandle + //ģʽ axisnum ÖáºÅ +output: //λÖñȽÏÊä³öÊ£Ó໺³åÊý buff +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetHwPswitchBuff(ZMC_HANDLE handle,int axisnum,int * buff); + +/************************************************************* +Description: //Ó²¼þ¶¨Ê±Æ÷ÓÃÓÚÓ²¼þ±È½ÏÊä³öºóÒ»¶Îʱ¼äºó»¹Ô­µçƽ 4ϵÁвúÆ·Ö§³Ö +Input: //¿¨Á´½Ó handle + //ģʽ mode 0Í£Ö¹, 2-Æô¶¯ + //ÖÜÆÚʱ¼ä cyclonetime usµ¥Î» + //ÓÐЧʱ¼ä optime usµ¥Î» + //ÖØ¸´´ÎÊý reptimes + //Êä³öȱʡ״̬ opstate Êä³ö¿Ú±äΪ·Ç´Ë״̬ºó¿ªÊ¼¼ÆÊ± + // Êä³ö¿Ú±àºÅ opnum ±ØÐëÄÜÓ²¼þ±È½ÏÊä³öµÄ¿Ú +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_HwTimer(ZMC_HANDLE handle,int mode,int cyclonetime,int optime,int reptimes,int opstate,int opnum ); + +/************************************************************* +Description: //¶ÁÈ¡ÖáÍ£Ö¹Ô­Òò +Input: //¿¨Á´½Óhandle +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetAxisStopReason(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //¸¡µãÐͶÁÈ«²¿Öá²ÎÊý״̬ +Input: //¿¨Á´½Óhandle +Input: // +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetAllAxisPara(ZMC_HANDLE handle,const char *sParam,int imaxaxis,float * pfValue); + +/************************************************************* +Description: //¸¡µãÐͶÁÈ«²¿Öá²ÎÊý״̬ +Input: //¿¨Á´½Óhandle +Input: // +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetAllAxisInfo(ZMC_HANDLE handle,int imaxaxis,int * IdleStatus,float * DposStatus,float * MposStatus,int * AxisStatus); + +/************************************************************* +Description: //ÉèÖÃBASIC×Ô¶¨ÒåÈ«¾ÖÊý×é +Input: //¿¨Á´½Óhandle + arrayname Êý×éÃû³Æ + arraystart Êý×éÆðÊ¼ÔªËØ + numes ÔªËØÊýÁ¿ + pfValue ÉèÖÃÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetUserArray(ZMC_HANDLE handle,char * arrayname ,int arraystart, int numes, float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡ÉèÖÃBASIC×Ô¶¨ÒåÈ«¾ÖÊý×é , ¿ÉÒÔÒ»´Î¶ÁÈ¡¶à¸ö +Input: //¿¨Á´½Óhandle + arrayname Êý×éÃû³Æ + arraystart Êý×éÆðÊ¼ÔªËØ + numes ÔªËØÊýÁ¿ +Output: //pfValue ¶à¸öʱ±ØÐë·ÖÅä¿Õ¼ä. +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetUserArray(ZMC_HANDLE handle,char * arrayname ,int arraystart, int numes, float *pfValue); + +/************************************************************* +Description: //ÉèÖÃ×Ô¶¨Òå±äÁ¿, +Input: //¿¨Á´½Óhandle + varname ±äÁ¿Ãû³Æ×Ö·û´® + pfValue É趨ֵ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetUserVar(ZMC_HANDLE handle,char * varname , float pfValue); + +/************************************************************* +Description: //¶ÁÈ¡×Ô¶¨ÒåÈ«¾Ö±äÁ¿, +Input: //¿¨Á´½Óhandle + varname ±äÁ¿Ãû³Æ×Ö·û´® +Output: //pfValue ±äÁ¿Öµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetUserVar(ZMC_HANDLE handle,char * varname , float *pfValue); + +/************************************************************* +Description: //¶ÁÈ¡PCIµÄ¿ØÖÆ¿¨¸öÊý +Input: // +Output: //¿¨Á´½Óhandle +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_GetMaxPciCards(); + +/************************************************************* +Description: //Óë¿ØÖÆÆ÷½¨Á¢Á´½Ó +Input: //PCI¿¨ºÅ£¬ +Output: //¿¨Á´½Óhandle +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_OpenPci(uint32 cardnum, ZMC_HANDLE * phandle); + +/************************************************************* +Description: //»ñÈ¡¿ØÖÆÆ÷¿¨ÐÅÏ¢ +Input: //¿¨Á´½Óhandle +Output: SoftType ¿ØÖÆÆ÷ÐͺÅÀàÐÍ + SoftVersion ¿ØÖÆÆ÷Èí¼þ°æ±¾£¨¹Ì¼þ°æ±¾£© + ControllerId ¿ØÖÆÆ÷ΨһID +Return: //´íÎóÂë +uint32 __stdcall ZMC_GetSoftVersion(ZMC_HANDLE handle,float *pVersion, char *pVerString, uint32 *pVerDate); +*************************************************************/ +int32 __stdcall ZAux_GetControllerInfo(ZMC_HANDLE handle ,char * SoftType ,char * SoftVersion ,char * ControllerId); + +/************************************************************* +Description: //¶ÁÈ¡¿¨²ÛÉϽڵãÊýÁ¿ +Input: //¿¨Á´½Óhandle +: //slot ²ÛλºÅȱʡ0 +Output: //piValue ·µ»ØÉ¨Ãè³É¹¦½ÚµãÊýÁ¿ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_GetNodeNum(ZMC_HANDLE handle,int slot,int *piValue); + +/************************************************************* +Description: //¶ÁÈ¡½ÚµãÉϵÄÐÅÏ¢ +Input: //¿¨Á´½Óhandle + slot ²ÛλºÅ + node ½Úµã±àºÅ + sel ÐÅÏ¢±àºÅ 0-³§É̱àºÅ1-É豸±àºÅ 2-É豸°æ±¾ 3-±ðÃû 10-IN¸öÊý 11-OUT¸öÊý +Output: //piValue ·µ»ØÐÅÏ¢ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_GetNodeInfo(ZMC_HANDLE handle,int slot,int node,int sel ,int *piValue); + +/************************************************************* +Description: //¶ÁÈ¡½Úµã×ÜÏß״̬ +Input: //¿¨Á´½Óhandle + slot ²ÛλºÅȱʡ0 + node ½Úµã±àºÅ +Output: //nodestatus °´Î»´¦Àí bit0-½ÚµãÊÇ·ñ´æÔÚ bit1-ͨѶ״̬ bit2-½Úµã״̬ +ֵΪ1ʱ£¬bit0Ϊ1£¬bit1ºÍbit2Ϊ0£¬É豸ͨѶÕý³£ +ֵΪ3ʱ£¬bit0ºÍbit1Ϊ1£¬bit2Ϊ0£¬É豸ͨѶ³ö´í +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_GetNodeStatus(ZMC_HANDLE handle,uint32 slot,uint32 node,uint32 * nodestatus); + +/************************************************************* +Description: //¶ÁÈ¡½ÚµãSDO²ÎÊýÐÅÏ¢ +Input: //¿¨Á´½Óhandle + slot ²ÛλºÅ ȱʡ0 + node ½Úµã±àºÅ + index ¶ÔÏó×Öµä±àºÅ£¨×¢Ò⺯ÊýΪ10½øÖÆÊý¾Ý£© + subindex ×Ó±àºÅ £¨×¢Ò⺯ÊýΪ10½øÖÆÊý¾Ý£© + type Êý¾ÝÀàÐÍ 1-bool 2-int8 3-int16 4-int32 5-uint8 6-uint16 7-uint32 +Output: //value ¶ÁÈ¡µÄÊý¾ÝÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_SDORead(ZMC_HANDLE handle,uint32 slot,uint32 node,uint32 index,uint32 subindex ,uint32 type ,int32 * value, uint32 tableNumber = 0); + +/************************************************************* +Description: //д½ÚµãSDO²ÎÊýÐÅÏ¢ +Input: //¿¨Á´½Óhandle + slot ²ÛλºÅ ȱʡ0 + node ½Úµã±àºÅ + index ¶ÔÏó×Öµä±àºÅ£¨×¢Ò⺯ÊýΪ10½øÖÆÊý¾Ý£© + subindex ×Ó±àºÅ £¨×¢Ò⺯ÊýΪ10½øÖÆÊý¾Ý£© + type Êý¾ÝÀàÐÍ 1-bool 2-int8 3-int16 4-int32 5-uint8 6-uint16 7-uint32 + value É趨µÄÊý¾ÝÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_SDOWrite(ZMC_HANDLE handle,uint32 slot,uint32 node,uint32 index,uint32 subindex ,uint32 type ,int32 value); + +/************************************************************* +Description: //¶ÁÈ¡ÖáSDO²ÎÊýÐÅÏ¢ +Input: //¿¨Á´½Óhandle + slot ²ÛλºÅ ȱʡ0 + node ½Úµã±àºÅ + index ¶ÔÏó×Öµä±àºÅ£¨×¢Ò⺯ÊýΪ10½øÖÆÊý¾Ý£© + subindex ×Ó±àºÅ £¨×¢Ò⺯ÊýΪ10½øÖÆÊý¾Ý£© + type Êý¾ÝÀàÐÍ 1-bool 2-int8 3-int16 4-int32 5-uint8 6-uint16 7-uint32 +Output: //value ¶ÁÈ¡µÄÊý¾ÝÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_SDOReadAxis(ZMC_HANDLE handle,uint32 iaxis,uint32 index,uint32 subindex ,uint32 type ,int32 * value, uint32 tableNumber=0); + +/************************************************************* +Description: //дÖáSDO²ÎÊýÐÅÏ¢ +Input: //¿¨Á´½Óhandle + slot ²ÛλºÅ ȱʡ0 + node ½Úµã±àºÅ + index ¶ÔÏó×Öµä±àºÅ£¨×¢Ò⺯ÊýΪ10½øÖÆÊý¾Ý£© + subindex ×Ó±àºÅ £¨×¢Ò⺯ÊýΪ10½øÖÆÊý¾Ý£© + type Êý¾ÝÀàÐÍ 1-bool 2-int8 3-int16 4-int32 5-uint8 6-uint16 7-uint32 + value É趨µÄÊý¾ÝÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_SDOWriteAxis(ZMC_HANDLE handle,uint32 iaxis,uint32 index,uint32 subindex ,uint32 type ,int32 value); + +/************************************************************* +Description: //Rtex¶ÁÈ¡²ÎÊýÐÅÏ¢ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + ipara ²ÎÊý·ÖÀà*256 + ²ÎÊý±àºÅ Pr7.11-ipara = 7*256+11 +Output: //value ¶ÁÈ¡µÄÊý¾ÝÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_RtexRead(ZMC_HANDLE handle,uint32 iaxis ,uint32 ipara ,float * value); + +/************************************************************* +Description: //Rtexд²ÎÊýÐÅÏ¢ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + ipara ²ÎÊý·ÖÀà*256 + ²ÎÊý±àºÅ Pr7.11-ipara = 7*256+11 + value É趨µÄÊý¾ÝÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_RtexWrite(ZMC_HANDLE handle,uint32 iaxis ,uint32 ipara,float value); + + +/************************************************************* +Description: //ÉèÖûØÁãÆ«ÒÆ¾àÀë +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fValue Æ«ÒÆ¾àÀë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_SetDatumOffpos(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁÈ¡»ØÁãÆ«ÒÆ¾àÀë +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //fValue ·´À¡µÄÆ«ÒÆ¾àÀë +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_GetDatumOffpos(ZMC_HANDLE handle, int iaxis, float * fValue); + +/************************************************************* +Description: //×ÜÏßÇý¶¯Æ÷»ØÁã +Input: //¿¨Á´½Óhandle + homemode »ØÁãģʽ£¬²é¿´Çý¶¯Æ÷ÊÖ²á +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_Datum(ZMC_HANDLE handle,uint32 iaxis,uint32 homemode); + +/************************************************************* +Description: //Çý¶¯Æ÷»ØÁãÍê³É״̬ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //homestatus »ØÁãÍê³É±êÖ¾ 0-»ØÁãÒì³£ 1»ØÁã³É¹¦ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_GetHomeStatus(ZMC_HANDLE handle,uint32 iaxis,uint32 * homestatus); + +/************************************************************* +Description: //ÉèÖÃÇå³ý×ÜÏßËÅ·þ±¨¾¯ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + mode ģʽ 0-Çå³ýµ±Ç°¸æ¾¯ 1-Çå³ýÀúÊ·¸æ¾¯ 2-Çå³ýÍⲿÊäÈë¸æ¾¯ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_DriveClear(ZMC_HANDLE handle,uint32 iaxis,uint32 mode); + +/************************************************************* +Description: //¶ÁÈ¡µ±Ç°×ÜÏßÇý¶¯µ±Ç°Á¦¾Ø ÐèÒªÉèÖöÔÓ¦µÄDRIVE_PROFILEÀàÐÍ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue µ±Ç°×ª¾Ø +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_GetDriveTorque(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖõ±Ç°×ÜÏßÇý¶¯×î´óת¾Ø ÐèÒªÉèÖöÔÓ¦µÄDRIVE_PROFILEÀàÐÍ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + piValue ×î´óת¾ØÏÞÖÆ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_SetMaxDriveTorque(ZMC_HANDLE handle, int iaxis, int piValue); + +/************************************************************* +Description: //¶ÁÈ¡µ±Ç°×ÜÏßÇý¶¯×î´óת¾Ø ÐèÒªÉèÖöÔÓ¦µÄDRIVE_PROFILEÀàÐÍ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //piValue ·µ»ØµÄ×î´óת¾Ø +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_GetMaxDriveTorque(ZMC_HANDLE handle, int iaxis, int *piValue); + +/************************************************************* +Description: //ÉèÖÃÄ£ÄâÁ¿Êä³ö Á¦¾Ø¡¢ËÙ¶ÈģʽÏ¿ÉÒÔ ×ÜÏßÇý¶¯ÐèÒªÉèÖöÔÓ¦DRIVE_PROFILEÀàÐÍ ÓëATYPE +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + Ä£ÄâÁ¿ Êä³öÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetDAC(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //¶ÁȡģÄâÁ¿Êä³ö Á¦¾Ø¡¢ËÙ¶ÈģʽÏ¿ÉÒÔ ×ÜÏßÇý¶¯ÐèÒªÉèÖöÔÓ¦DRIVE_PROFILEÀàÐÍ ÓëATYPE +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //fValue Ä£ÄâÁ¿·µ»ØÖµ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetDAC(ZMC_HANDLE handle, int iaxis, float *fValue); + +/************************************************************* +Description: //×ÜÏß³õʼ»¯ £¨Õë¶ÔZmotion tools ¹¤¾ßÈí¼þÅäÖùý×ÜÏß²ÎÊý¿ØÖÆÆ÷ʹÓÃÓÐЧ£© +Input: //¿¨Á´½Óhandle +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_InitBus(ZMC_HANDLE handle); + +/************************************************************* +Description: //»ñÈ¡×ÜÏß³õʼ»¯Íê³É״̬ £¨Õë¶ÔZmotion tools ¹¤¾ßÈí¼þÅäÖùý×ÜÏß²ÎÊý¿ØÖÆÆ÷ʹÓÃÓÐЧ£© +Input: //¿¨Á´½Óhandle +Output: // +Return: //´íÎóÂëpiValue 0-³õʼ»¯Ê§°Ü 1³É¹¦ +*************************************************************/ +int32 __stdcall ZAux_BusCmd_GetInitStatus(ZMC_HANDLE handle,int *piValue); + +/************************************************************* +Description: //¶ÁÈ¡¶à¸öÊäÈëÐźŠ+Input: //¿¨Á´½Óhandle +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetInMulti(ZMC_HANDLE handle, int startio , int endio, int32 *piValue); + +/************************************************************* +Description: //ÃüÁîµÄÑÓʱµÈ´ýʱ¼ä +Input: //¿¨Á´½Óhandle ºÁÃëʱ¼ä +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_SetTimeOut(ZMC_HANDLE handle, uint32 timems); + +/*************************************************************2017-12-14Ìí¼Ó +Description: //Ó²¼þλÖñȽÏÊä³ö2 4ϵÁвúÆ·, 20170513ÒÔÉϰ汾֧³Ö. ZMC306E/306NÖ§³Ö +Input: //¿¨Á´½Ó handle + //ģʽ mode + //Êä³ö¿Ú±àºÅ Opnum 4ϵÁÐ out 0-3ΪӲ¼þλÖñȽÏÊä³ö + //µÚÒ»¸ö±È½ÏµãµÄÊä³ö״̬ Opstate 0-¹Ø±Õ 1´ò¿ª + //¶à¹¦ÄܲÎÊý ModePara1 + //¶à¹¦ÄܲÎÊý ModePara2 + //¶à¹¦ÄܲÎÊý ModePara3 + //¶à¹¦ÄܲÎÊý ModePara4 + +mode 1-Æô¶¯±È½ÏÆ÷, + ModePara1 = µÚÒ»¸ö±È½Ïµã×ø±êËùÔÚTABLE±àºÅ + ModePara2 = ×îºóÒ»¸ö±È½Ïµã×ø±êËùÔÚTABLE±àºÅ + ModePara3 = µÚÒ»¸öµãÅжϷ½Ïò, 0-×ø±ê¸ºÏò, 1- ×ø±êÕýÏò, -1-²»Ê¹Ó÷½Ïò + ModePara4 = Ô¤Áô + ModePara5= Ô¤Áô + ModePara6= Ô¤Áô +mode 2- Í£Ö¹²¢É¾³ýûÍê³ÉµÄ±È½Ïµã. + ModePara1 = Ô¤Áô + ModePara2 = Ô¤Áô + ModePara3 = Ô¤Áô + ModePara4 = Ô¤Áô + ModePara5= Ô¤Áô + ModePara6= Ô¤Áô +//ʹÓÃʸÁ¿¾àÀë±È½Ïʱ£¬ÓëVECTOR_MOVED½øÐбȽϣ¬½¨ÒéÁ¬ÐøÔ˶¯Ç°ÉèÖÃVECTOR_MOVED³õʼֵ +mode 3- ʸÁ¿±È½Ï·½Ê½ + ModePara1 = µÚÒ»¸ö±È½Ïµã×ø±êËùÔÚTABLE±àºÅ + ModePara2 = ×îºóÒ»¸ö±È½Ïµã×ø±êËùÔÚTABLE±àºÅ + ModePara3 = Ô¤Áô + ModePara4 = Ô¤Áô + ModePara5= Ô¤Áô + ModePara6= Ô¤Áô +Mode=4 :ʸÁ¿±È½Ï·½Ê½, µ¥¸ö±È½Ïµã + ModePara1 = ±È½Ïµã×ø±ê + ModePara2 = Ô¤Áô + ModePara3 = Ô¤Áô + ModePara4 = Ô¤Áô + ModePara5= Ô¤Áô + ModePara6= Ô¤Áô +Mode=5 :ʸÁ¿±È½Ï·½Ê½, ÖÜÆÚÂö³åģʽ + ModePara1 = ±È½Ïµã×ø±ê VECTOR_MOVEDµ±Ç°Ô˶¯¾àÀë + ModePara2 = ÖØ¸´ÖÜÆÚ, Ò»¸öÖÜÆÚÄڱȽÏÁ½´Î, ÏÈÊä³öÓÐЧ״̬,ÔÙÊä³öÎÞЧ״̬. + ModePara3 = ÖÜÆÚ¾àÀë, ÿ¸ôÕâ¸ö¾àÀëÊä³öOpstate, Êä³öÓÐЧ״̬µÄ¾àÀ루ModePara4£©ºó»¹Ô­ÎªÎÞЧ״̬. + ModePara4 = Êä³öÓÐЧ״̬µÄ¾àÀë, (ModePara3- ModePara4) ΪÎÞЧ״̬¾àÀë + ModePara5= Ô¤Áô + ModePara6= Ô¤Áô +Mode=6 :ʸÁ¿±È½Ï·½Ê½, ÖÜÆÚģʽ, ÕâÖÖģʽһ°ãÓëHW_TIMERÒ»ÆðʹÓÃ. + ModePara1 = ±È½Ïµã×ø±ê ±È½ÏµãVECTOR_MOVEDµ±Ç°Ô˶¯¾àÀë + ModePara2 = ÖØ¸´ÖÜÆÚ, Ò»¸öÖÜÆÚÖ»±È½ÏÒ»´Î + ModePara3 = ÖÜÆÚ¾àÀë, ÿ¸ôÕâ¸ö¾àÀëÊä³öÒ»´Î + ModePara4 = Ô¤Áô + ModePara5= Ô¤Áô + ModePara6= Ô¤Áô +Mode=7£ºÓëHW_TIMERÒ»ÆðʹÓà + ModePara1 = µÚÒ»¸ö±È½ÏµãVECTOR_MOVED×ø±êËùÔÚTABLE±àºÅ + ModePara2 = ×îºóÒ»¸ö±È½ÏµãVECTOR_MOVED×ø±êËùÔÚTABLE±àºÅ + [Óëhwtimer²¢ÓÃʱ£¬¿ÉÒÔ¶¯Ì¬µ÷Õûhwtimer²ÎÊý] + ModePara3 = ¶¯Ì¬µ÷ÕûHW_TIMERµÄÓÐЧʱ¼ä + ModePara4 = ¶¯Ì¬µ÷ÕûHW_TIMERµÄ´¥·¢Âö³åÊý£¬0-²»Êä³ö + ModePara5= ¶¯Ì¬µ÷ÕûHW_TIMERµÄÂö³åÖÜÆÚʱ¼ä + ModePara6= Ô¤Áô +Mode=25, 26, 35,36, ¶àάµÄ±È½Ïģʽ 25,26 2D±È½Ïģʽ 35,36 3D±È½Ïģʽ + ModePara1 = ±È½ÏλÖÃÿ¸öÖá×óÓÒµÄÂö³åÆ«²î£¬½øÈëÆ«²î·¶Î§ºó¿ªÊ¼±È½Ï + ModePara2 = tableÀïÃæ´æ´¢µÄ±È½Ïµã¸öÊý + ModePara3 = µÚÒ»¸ö±È½Ïµã×ø±êËùÔÚtable±àºÅ + [Óëhwtimer²¢ÓÃʱ£¬¿ÉÒÔ¶¯Ì¬µ÷Õûhwtimer²ÎÊý] + ModePara4 = Âö³åʱ¼ä + ModePara5= Âö³å¸öÊý + ModePara6= Âö³åÖÜÆÚ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_HwPswitch2(ZMC_HANDLE handle,int Axisnum,int Mode, int Opnum , int Opstate, float ModePara1, float ModePara2,float ModePara3,float ModePara4, float ModePara5,float ModePara6); + +/************************************************************* +Description: //»ñÈ¡¿ØÖÆÆ÷×î´ó¹æ¸ñÊý +Input: //¿¨Á´½Óhandle +Output: //Max_VirtuAxises ×î´óÐéÄâÖáÊý +Output: //Max_motor ×î´óµç»úÊýÁ¿ +Output: //Max_io ×î´óIN,OUT,AD,DAÊýÁ¿ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_GetSysSpecification(ZMC_HANDLE handle, uint16 * Max_VirtuAxises,uint8 * Max_motor,uint8 *Max_io); + +/************************************************************* +Description: ////Ö÷¶¯Éϱ¨µÄ»Øµ÷º¯Êý¸ñʽ +Input: //¿¨Á´½Óhandle + itypecode: ÉÏ´«ÀàÐÍÂë + idatalength: Êý¾Ý³¤¶È + pdata: Êý¾ÝÖ¸Õë +Output: // +Return: //´íÎóÂë +*************************************************************/ +typedef void (*PZMCAutoUpCallBack)(ZMC_HANDLE handle, int32 itypecode, int32 idatalength, uint8 *pdata); + +/************************************************************* +Description: //¿ØÖÆÆ÷×Ô¶¯Éϱ¨ +Input: //¿¨Á´½Ó handle +Input: //»Øµ÷º¯Êý pcallback +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_SetAutoUpCallBack(ZMC_HANDLE handle, PZMCAutoUpCallBack pcallback); + +/************************************************************* +Description: //IO½Ó¿Ú ÉèÖÃÊä³ö +Input: //¿¨Á´½Óhandle +Input: //IO¿ÚÆðʼ±àºÅ iofirst +Input: //IO¿Ú½áÊø±àºÅ ioend +Input: //Êä³ö¿Ú״̬ istate°´Î»´æ´¢£¬Ò»¸öUINT´æ´¢32¸öÊä³ö¿Ú״̬ +Output: //״̬ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetOutMulti(ZMC_HANDLE handle, uint16 iofirst,uint16 ioend, uint32 * istate); + +/************************************************************* +Description: //IO½Ó¿Ú ÉèÖöà·Êä³ö +Input: //¿¨Á´½Óhandle +Input: //IO¿ÚÆðʼ±àºÅ iofirst +Input: //IO¿Ú½áÊø±àºÅ ioend +Input: //Êä³ö¿Ú״̬ istate°´Î»´æ´¢£¬Ò»¸öUINT´æ´¢32¸öÊä³ö¿Ú״̬ +Output: //״̬ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetOutMulti(ZMC_HANDLE handle, uint16 iofirst,uint16 ioend, uint32 * istate); + +/************************************************************* +Description: //¶àÌõÏà¶Ô¶àÖáÖ±Ï߲岹 +Input: //¿¨Á´½Óhandle + iMoveLen ÌîдµÄÔ˶¯³¤¶È + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + pfDisancelist ¾àÀëÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MultiMove(ZMC_HANDLE handle,int iMoveLen, int imaxaxises, int *piAxislist, float *pfDisancelist); + +/************************************************************* +Description: //¶àÌõÏà¶Ô¶àÖáÖ±Ï߲岹 +Input: //¿¨Á´½Óhandle + iMoveLen ÌîдµÄÔ˶¯³¤¶È + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + pfDisancelist ¾àÀëÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MultiMoveAbs(ZMC_HANDLE handle,int iMoveLen, int imaxaxises, int *piAxislist, float *pfDisancelist); + +/************************************************************* +Description: //»úеÊÖ×ø±êϵÐýת +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ ¹Ø½ÚÖá/ÐéÄâÖá + pfRotatePara Æ½ÒÆÐýת²ÎÊý +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_FrameRotate(ZMC_HANDLE handle,int iaxis, float *pfRotatePara); + +/************************************************************* +Description: //»ñÈ¡CANÀ©Õ¹×ÊÔ´¹æ¸ñ +Input: //¿¨Á´½Óhandle +Output: //CanNum µ±Ç°Á¬½ÓµÄCAN´ÓÕ¾ÊýÁ¿ +Output: //CanId_List µ±Ç°Á¬½ÓµÄCAN´ÓÕ¾IDÁбí +Output: //CanIn_List ½ÚµãÊäÈëµãÊýÁ¿ +Output: //CanOut_List ½ÚµãÊä³öµãÊýÁ¿ +Output: //CanAin_List ½ÚµãADÊýÁ¿ +Output: //CanAOut_List ½ÚµãDAÊýÁ¿ +Output: //CanAxis_List ½ÚµãÖáÊýÁ¿ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_GetCanInfo(ZMC_HANDLE handle, uint8 * CanNum,uint16 * CanId_List,uint8 *CanIn_List,uint8 *CanOut_List,uint8 *CanAin_List,uint8 *CanAOut_List,uint8 *CanAxis_List); + +/************************************************************* +Description: //¶àÌõÏà¶ÔPTÔ˶¯ +Input: //¿¨Á´½Óhandle + iMoveLen ÌîдµÄÔ˶¯ÊýÁ¿ + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + pTickslist ÖÜÆÚÁбí + pfDisancelist ¾àÀëÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MultiMovePt(ZMC_HANDLE handle,int iMoveLen, int imaxaxises, int *piAxislist, uint32 *pTickslist, float *pfDisancelist); + +/************************************************************* +Description: //¶àÌõ¾ø¶ÔPTÔ˶¯ +Input: //¿¨Á´½Óhandle + iMoveLen ÌîдµÄÔ˶¯ÊýÁ¿ + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + pTickslist ÖÜÆÚÁбí + pfDisancelist ¾àÀëÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MultiMovePtAbs(ZMC_HANDLE handle,int iMoveLen, int imaxaxises, int *piAxislist, uint32 *pTickslist, float *pfDisancelist); + +/************************************************************* +Description: //ÏÂÔØZARÎļþµ½¿ØÖÆ +Input: //¿¨Á´½Óhandle +Input: //Filename Îļþ·¾¶ +Input: //ÏÂÔØµ½RAM-ROM 0-RAM 1-ROM +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_ZarDown(ZMC_HANDLE handle,const char *Filename,uint32 run_mode); + +/************************************************************* +Description: //¶ÁÈ¡RTCʱ¼ä +Input: //¿¨Á´½Óhandle +Input: //ϵͳÈÕÆÚ ¸ñʽYYMMDD +Input: //ϵͳʱ¼ä ¸ñʽHHMMSS +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_GetRtcTime(ZMC_HANDLE handle,char * RtcDate,char * RtcTime); + +/************************************************************* +Description: //ÉèÖÃRTCʱ¼ä +Input: //¿¨Á´½Óhandle +Input: //ϵͳÈÕÆÚ ¸ñʽYYMMDD +Input: //ϵͳʱ¼ä ¸ñʽHHMMSS +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_SetRtcTime(ZMC_HANDLE handle,char * RtcDate,char * RtcTime); + +/************************************************************* +Description: //Óë¿ØÖÆÆ÷½¨Á¢Á´½Ó, ¿ÉÒÔÖ¸¶¨Á¬½ÓµÄµÈ´ýʱ¼ä +Input: //Á¬½ÓÀàÐÍ type 1-COM 2-ETH 3-Ô¤ÁôUSB 4-PCI +Input: //Á¬½Ó×Ö·û´® pconnectstring COM¿ÚºÅ/IPµØÖ· +Input: //Á¬½Ó³¬Ê±Ê±¼ä uims +Output: //¿¨Á´½Óhandle +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_FastOpen(int type, char *pconnectstring, uint32 uims ,ZMC_HANDLE * phandle); + +/************************************************************* +Description: //×Ô¶¨Òå¶þ´Î»ØÁã +Input: //¿¨Á´½Óhandle +Input: //ÖáºÅiaxis +Input: //»ØÁãģʽimode +Input: //»ØÁã¸ßËÙHighSpeed +Input: //»ØÁãµÍËÙHighSpeed +Input: //¶þ´Î»ØÁãÆ«ÒÆ¾àÀëDatumOffset +Output: // +Return: // +*************************************************************/ +int32 __stdcall ZAux_Direct_UserDatum(ZMC_HANDLE handle, int iaxis, int imode,float HighSpeed,float LowSpeed,float DatumOffset); + +/************************************************************* +Description: //ÉèÖÃÖáµÄÂݾಹ³¥£¬À©Õ¹ÖáÎÞЧ¡£ +Input: //handle ¾ä±úÁ¬½Ó +Input: //iaxis ÖáºÅ +Input: //iEnable ÊÇ·ñÆôÓò¹³¥ +Input: //StartPos Æðʼ²¹³¥MPOSλÖà +Input: //maxpoint ²¹³¥Çø¼ä×ܵãÊý +Input: //DisOne ÿ¸ö²¹³¥µã¼ä¾à +Input: //TablNum ²¹³¥×ø±êÖµÌîÈëTABLEϵͳÊý×éÆðʼÒýµ¼µØÖ· +Input: //pfDisancelist Çø¼ä²¹³¥ÖµÁбí +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Pitchset(ZMC_HANDLE handle,int iaxis,int iEnable,float StartPos,uint32 maxpoint,float DisOne ,uint32 TablNum,float * pfDisancelist); + +/************************************************************* +Description: //ÉèÖÃÖáµÄÂݾàË«Ïò²¹³¥£¬À©Õ¹ÖáÎÞЧ¡£ +Input: //handle ¾ä±úÁ¬½Ó +Input: //iaxis ÖáºÅ +Input: //iEnable ÊÇ·ñÆôÓò¹³¥ +Input: //StartPos Æðʼ²¹³¥MPOSλÖà +Input: //maxpoint ²¹³¥Çø¼ä×ܵãÊý +Input: //DisOne ÿ¸ö²¹³¥µã¼ä¾à +Input: //TablNum ²¹³¥×ø±êÖµÌîÈëTABLEϵͳÊý×éÆðʼÒýµ¼µØÖ· +Input: //pfDisancelist Çø¼ä²¹³¥ÖµÁбí +Input: //RevTablNum ·´Ïò²¹³¥×ø±êÖµÌîÈëTABLEϵͳÊý×éÆðʼÒýµ¼µØÖ· +Input: //RevpfDisancelist ·´ÏòÇø¼ä²¹³¥ÖµÁÐ±í ²¹³¥Êý¾Ý·½ÏòÓÚÕýÏò·½ÏòÒ»Ö +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Pitchset2(ZMC_HANDLE handle,int iaxis,int iEnable,float StartPos,uint32 maxpoint,float DisOne ,uint32 TablNum,float * pfDisancelist ,uint32 RevTablNum,float * RevpfDisancelist); + + +/************************************************************* +Description: //ÉèÖÃÖáµÄÂݾಹ³¥×´Ì¬¶ÁÈ¡£¬À©Õ¹ÖáÎÞЧ¡£ +Input: //handle ¾ä±úÁ¬½Ó +Input: //iaxis ÖáºÅ +Output: //IfEnable ÊÇ·ñÆôÓò¹³¥ 0¹Ø±Õ -1¿ªÆô +Output: //PitchDist ²¹³¥¾àÀë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetPitchStatus(ZMC_HANDLE handle,int iaxis,int * IfEnable,float * PitchDist); + +// Ôö¼ÓÖÜÆÚÉϱ¨µÄº¯ÊýÉèÖà +/************************************************************* +Description: // ʹÄÜÖÜÆÚÉϱ¨ +Input: // cycleindex: Éϱ¨Í¨µÀºÅ, 0-×î´óÖµ-1 + fintervalms: Éϱ¨¼ä¸ôʱ¼ä, msµ¥Î», ²»ÄܵÍÓÚ¿ØÖÆÆ÷SERVO_PERIOD + psetesname Éϱ¨²ÎÊýÑ¡Ôñ, Óï·¨: ²ÎÊý1, ²ÎÊý2(index), ²ÎÊý3(index, numes) +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_CycleUpEnable(ZMC_HANDLE handle, uint32 cycleindex, float fintervalms, const char* psetesname); + + +/************************************************************* +Description: // ȥʹÄÜÖÜÆÚÉϱ¨ +Input: // cycleindex: Éϱ¨Í¨µÀºÅ, 0-×î´óÖµ-1 +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_CycleUpDisable(ZMC_HANDLE handle, uint32 cycleindex); + + + +/************************************************************* +Description: // ÖÜÆÚÉϱ¨ÊÕµ½µÄ°ü´ÎÊý, ³¬¹ýÒç³ö. µ÷ÊÔʹÓÃ. +Input: // +Output: // +Return: // ´ÎÊý +*************************************************************/ +uint32 __stdcall ZAux_CycleUpGetRecvTimes(ZMC_HANDLE handle, uint32 cycleindex); + + +/************************************************************* +Description: // Ç¿ÖÆÉϱ¨Ò»´Î, ÔÚÔ˶¯Ö¸Áîºóidle¿ÉÄܲ»×¼È·µÄÇé¿öϵ÷ÓÃ. +Input: // +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_CycleUpForceOnce(ZMC_HANDLE handle, uint32 cycleindex); + + +/************************************************************* +Description: // ´ÓÖÜÆÚÉϱ¨ÀïÃæ¶ÁÈ¡ÄÚÈÝ +Input: // cycleindex -1, ×Ô¶¯Ñ¡Ôñcycle±àºÅ + psetname ²ÎÊýÃû³Æ + isetindex ²ÎÊý±àºÅ + +Output: // pvalue ·µ»ØÖµ +Return: // ´íÎóÂë +*************************************************************/ +uint32 __stdcall ZAux_CycleUpReadBuff(ZMC_HANDLE handle, uint32 cycleindex, const char *psetname, uint32 isetindex, double *pvalue); + + + +/************************************************************* +Description: // ´ÓÖÜÆÚÉϱ¨ÀïÃæ¶ÁÈ¡ÄÚÈÝ +Input: // cycleindex -1, ×Ô¶¯Ñ¡Ôñcycle±àºÅ + psetname ²ÎÊýÃû³Æ + isetindex ²ÎÊý±àºÅ + +Output: // pvalue ·µ»ØÖµ +Return: // ´íÎóÂë +*************************************************************/ +uint32 __stdcall ZAux_CycleUpReadBuffInt(ZMC_HANDLE handle, uint32 cycleindex, const char *psetname, uint32 isetindex, int32*pvalue); + + +/************************************************************* +Description: //¶àÖá¶à¶ÎÏßÖ±ÏßÁ¬Ðø²å²¹ +Input: //¿¨Á´½Óhandle + imode bit0- bifabs + bit1- bifsp ÊÇ·ñ + bit2- bifresume + bit3- bifmovescan µ÷Óà + iMoveLen ÌîдµÄÔ˶¯³¤¶È + imaxaxises ²ÎÓëÔ˶¯×ÜÖáÊý + piAxislist ÖáºÅÁбí + pfDisancelist ¾àÀëÁбí iMoveLen * imaxaxises +Output: // + iReBuffLen Ê£Ó໺³å¿ÉÒÔÏ·¢µÄ×î´óÃüÁ¶È +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MultiLineN(ZMC_HANDLE handle,int imode,int iMoveLen, int imaxaxises, int *piAxislist, float *pfDisancelist,int *iReBuffLen); + + +/************************************************************* +Description: //Ƥ´øÍ¬²½¸úËæÔ˶¯ +Input: //¿¨Á´½Óhandle + imode ͬ²½Ä£Ê½ -1½áÊøÄ£Ê½ -2Ç¿ÖÆ½áÊø 0-µÚÒ»¸öÖá¸úËæ 10-µÚ¶þ¸öÖá¸úËæ 20-µÚ¶þ¸öÖá¸úËæ СÊýλ-angle£ºÆ¤´øÐýת½Ç¶È + synctime ͬ²½Ê±¼ä£¬msµ¥Î»£¬±¾Ô˶¯ÔÚÖ¸¶¨Ê±¼äÄÚÍê³É£¬Íê³ÉʱBASEÖá¸úÉÏÆ¤´øÇÒ±£³ÖËÙ¶ÈÒ»Ö¡£0±íʾ¸ù¾ÝÔ˶¯ÖáµÄËٶȼÓËÙ¶ÈÀ´¹À¼ÆÍ¬²½Ê±¼ä£¬¿ÉÄܲ»×¼È· + syncposition Ƥ´øÖáÎïÌå±»¸ÐÓ¦µ½Ê±Æ¤´øÖáµÄλÖà + syncaxis Ƥ´øÖáÖáºÅ + imaxaxises ²ÎÓëͬ²½´ÓÖá×ÜÊý + piAxislist ´ÓÕ¾ÖáºÅÁбí + pfDisancelist Ƥ´øÖáÎïÌå±»¸ÐÓ¦µ½Ê±´ÓÖáµÄ¾ø¶Ô×ø±êλÖà +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveSync(ZMC_HANDLE handle,float imode,int synctime, float syncposition, int syncaxis, int imaxaxises, int *piAxislist, float *pfDisancelist); + +/************************************************************* +Description: //Á¬ÐøÎ»ÖÃËø´æÖ¸Áî +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + imode Ëø´æÄ£Ê½ + iTabStart Á¬ÐøËø´æµÄÄÚÈÝ´æ´¢µÄtableλÖ㬵ÚÒ»¸ötableÔªËØ´æ´¢Ëø´æµÄ¸öÊý£¬ºóÃæ´æ´¢Ëø´æµÄ×ø±ê£¬×î¶à±£´æ¸öÊý= numes-1£¬Òç³öʱѭ»·Ð´Èë + iTabNum Õ¼ÓõÄtable¸öÊý + +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_CycleRegist(ZMC_HANDLE handle,int iaxis, int imode,int iTabStart,int iTabNum); + +/************************************************************* +Description: //Ô˶¯ÖÐÈ¡ÏûÆäËûÖáÔ˶¯ +Input: //¿¨Á´½Óhandle + base_axis ²å²¹Ö÷Öá±àºÅ + table_num TABLE±àºÅ + fvalue ÐÞ¸ÄÖµ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveCancel(ZMC_HANDLE handle,int32 base_axis,int32 Cancel_Axis,int iMode); + + +/************************************************************* +Description: //Pdoд²Ù×÷ +Input: //¿¨Á´½Óhandle + inode ½ÚµãºÅ + index ¶ÔÏó×Öµä + subindex ×Ó¶ÔÏó + type Êý¾ÝÀàÐÍ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_NodePdoWrite(ZMC_HANDLE handle,uint32 inode,uint32 index,uint32 subindex ,uint32 type ,int value); + +/************************************************************* +Description: //Pdo¶Á²Ù×÷ +Input: //¿¨Á´½Óhandle + inode ½ÚµãºÅ + index ¶ÔÏó×Öµä + subindex ×Ó¶ÔÏó + type Êý¾ÝÀàÐÍ +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_BusCmd_NodePdoRead(ZMC_HANDLE handle,uint32 inode,uint32 index,uint32 subindex ,uint32 type ,int* ivalue); //PODдÊý¾Ý + +/************************************************************* +Description: //ʾ²¨Æ÷²ÉÑù +Input: //¿¨Á´½Óhandle + iEnable ÊÇ·ñʹÄÜ + iPeriod ²ÉÑùÖÜÆÚ ÒÔ¿ØÖÆÆ÷ÖÜÆÚΪµ¥Î» + iStartTab Êý¾ÝÆðʼTABLEλÖà + iStopTab Êý¾ÝÍ£Ö¹TABLEλÖà + iParaNum ²É¼¯Êý¾ÝÔ´ÊýÁ¿ ×î´ó8¸ö + ParaNamep ²É¼¯Êý¾ÝÔ´×Ö·ûÃû³Æ + +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Scope(ZMC_HANDLE handle,int iEnable,int iPeriod,int iStartTab,int iStopTab,int iParaNum ,char * ParaNamep[8]); + +/************************************************************* +Description: //»ñȡʾ²¨Æ÷²ÉÑù³¤¶È +Input: //¿¨Á´½Óhandle + +output: //Êý¾Ý³¤¶È ilen +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_GetScopeLength(ZMC_HANDLE handle,int *ilen); + +/************************************************************* +Description: //Ó²¼þλÖñȽÏÊä³ö Ô˶¯»º³å·½Ê½ +Input: //¿¨Á´½Ó handle + //ģʽ mode + //Êä³ö¿Ú±àºÅ Opnum 4ϵÁÐ out 0-3ΪӲ¼þλÖñȽÏÊä³ö + //µÚÒ»¸ö±È½ÏµãµÄÊä³ö״̬ Opstate 0-¹Ø±Õ 1´ò¿ª + //¶à¹¦ÄܲÎÊý ModePara1 + //¶à¹¦ÄܲÎÊý ModePara2 + //¶à¹¦ÄܲÎÊý ModePara3 + //¶à¹¦ÄܲÎÊý ModePara4 + +mode 1-Æô¶¯±È½ÏÆ÷, + ModePara1 = µÚÒ»¸ö±È½Ïµã×ø±êËùÔÚTABLE±àºÅ + ModePara2 = ×îºóÒ»¸ö±È½Ïµã×ø±êËùÔÚTABLE±àºÅ + ModePara3 = µÚÒ»¸öµãÅжϷ½Ïò, 0-×ø±ê¸ºÏò, 1- ×ø±êÕýÏò, -1-²»Ê¹Ó÷½Ïò + ModePara4 = Ô¤Áô + +mode 2- Í£Ö¹²¢É¾³ýûÍê³ÉµÄ±È½Ïµã. + ModePara1 = Ô¤Áô + ModePara2 = Ô¤Áô + ModePara3 = Ô¤Áô + ModePara4 = Ô¤Áô + +mode 3- ʸÁ¿±È½Ï·½Ê½ + ModePara1 = µÚÒ»¸ö±È½Ïµã×ø±êËùÔÚTABLE±àºÅ + ModePara2 = ×îºóÒ»¸ö±È½Ïµã×ø±êËùÔÚTABLE±àºÅ + ModePara3 = Ô¤Áô + ModePara4 = Ô¤Áô + +Mode=4 :ʸÁ¿±È½Ï·½Ê½, µ¥¸ö±È½Ïµã + ModePara1 = ±È½Ïµã×ø±ê + ModePara2 = Ô¤Áô + ModePara3 = Ô¤Áô + ModePara4 = Ô¤Áô + +Mode=5 :ʸÁ¿±È½Ï·½Ê½, ÖÜÆÚÂö³åģʽ + ModePara1 = ±È½Ïµã×ø±ê + ModePara2 = ÖØ¸´ÖÜÆÚ, Ò»¸öÖÜÆÚÄڱȽÏÁ½´Î, ÏÈÊä³öÓÐЧ״̬,ÔÙÊä³öÎÞЧ״̬. + ModePara3 = ÖÜÆÚ¾àÀë, ÿ¸ôÕâ¸ö¾àÀëÊä³öOpstate, Êä³öÓÐЧ״̬µÄ¾àÀ루ModePara4£©ºó»¹Ô­ÎªÎÞЧ״̬. + ModePara4 = Êä³öÓÐЧ״̬µÄ¾àÀë, (ModePara3- ModePara4) ΪÎÞЧ״̬¾àÀë + +Mode=6 :ʸÁ¿±È½Ï·½Ê½, ÖÜÆÚģʽ, ÕâÖÖģʽһ°ãÓëHW_TIMERÒ»ÆðʹÓÃ. + ModePara1 = ±È½Ïµã×ø±ê + ModePara2 = ÖØ¸´ÖÜÆÚ, Ò»¸öÖÜÆÚÖ»±È½ÏÒ»´Î + ModePara3 = ÖÜÆÚ¾àÀë, ÿ¸ôÕâ¸ö¾àÀëÊä³öÒ»´Î + ModePara4 = Ô¤Áô +Mode=7£ºÓëHW_TIMERÒ»ÆðʹÓà + ModePara1 = µÚÒ»¸ö±È½ÏµãVECTOR_MOVED×ø±êËùÔÚTABLE±àºÅ + ModePara2 = ×îºóÒ»¸ö±È½ÏµãVECTOR_MOVED×ø±êËùÔÚTABLE±àºÅ + ModePara3 = Ô¤Áô + ModePara4 = Ô¤Áô +Mode=25, 26, 35,36, ¶àάµÄ±È½Ïģʽ 25,26 2D±È½Ïģʽ 35,36 3D±È½Ïģʽ + ModePara1 = ±È½ÏλÖÃÿ¸öÖá×óÓÒµÄÂö³åÆ«²î£¬½øÈëÆ«²î·¶Î§ºó¿ªÊ¼±È½Ï + ModePara2 = tableÀïÃæ´æ´¢µÄ±È½Ïµã¸öÊý + ModePara3 = µÚÒ»¸ö±È½Ïµã×ø±êËùÔÚtable±àºÅ + ModePara4 = Ô¤Áô + +¶¯Ì¬ÐÞ¸ÄHW_TIMER²ÎÊý£¬Ê¹ÓÃǰÌáǰ´ò¿ªHW_TIMER + HwTimerOpenTime ÓÐЧʱ¼ä£¬usµ¥Î» + HwTimerCycTime ÖÜÆÚʱ¼ä£¬usµ¥Î» + HwTimerReptimes ÖØ¸´´ÎÊý + +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveHwPswitch2(ZMC_HANDLE handle,int Axisnum,int Mode, int Opnum , int Opstate, float ModePara1, float ModePara2,float ModePara3,float ModePara4,float HwTimerOpenTime,float HwTimerCycTime,int HwTimerReptimes); + + +/************************************************************* +Description: //·´Ïò¼ä϶²¹³¥ +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ + fdistance ¾àÀë +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_Backlash(ZMC_HANDLE handle, int iaxis, bool enable,float dist,float speed,float accel); + +/************************************************************* +Description: //table¶ÁÈ¡, ¿ÉÒÔÒ»´Î¶ÁÈ¡¶à¸ö +Input: //¿¨Á´½Óhandle + tabstart ¶ÁÈ¡TABLEÆðʼµØÖ· + numes ¶ÁÈ¡µÄÊýÁ¿ +Output: //pfValue ¶à¸öʱ±ØÐë·ÖÅä¿Õ¼ä. +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_GetTable2(ZMC_HANDLE handle, int tabstart, int numes, double *pdoubleValue); + +/************************************************************* +Description: //ÉèÖÃop¿ÚÌáǰ´ò¿ª»ò¹Ø±Õ +Input: //¿¨Á´½Óhandle + nAxis ÖáºÅ + uTime ÑÓʱʱ¼ä + nOpnum ¿ØÖÆÑÓʱÊä³ö¿Ú +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_MoveOpDelay(ZMC_HANDLE handle, float uTime, int nAxis); + +/************************************************************* +Description: //´ò°ü×Ö·û´®Ô˶¯Ï·¢ +Input: handle ¿¨Á´½Ó + pbuffer Ô˶¯×Ö·û´® + buffsize ×Ö·û´®ÏÂÔØ´óС + File3Name ÏÂÔØÉú³ÉµÄ3´ÎÎļþÃû³Æ + ifile3num 3´ÎÎļþ±àºÅ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_DownMem3FileRam(ZMC_HANDLE handle, const char* pbuffer, uint32 buffsize,char* File3Name,int32 ifile3num); + + +/************************************************************* +Description: //ÔËÐÐÏ·¢µÄÎļþ +Input: handle ¿¨Á´½Ó + ifile3num 3´ÎÎļþ±àºÅ + itasknum Ô˶¯ÈÎÎñºÅ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Run3FileRam(ZMC_HANDLE handle,int32 ifile3num ,int32 itasknum); + + +/************************************************************* +Description: // ¶¯Ì¬ÏÂÔØ3´ÎÎļþ, ¶ÁÈ¡¿ÉдµÄ¿Õ¼ä. +Input: //¿¨Á´½Óhandle + ifile3num 3´ÎÎļþ±àºÅ +Output: // //premainbyte Ê£Óà¿Ø¼þ×Ö½ÚÊý +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_3FileRamGetRemainSpace(ZMC_HANDLE handle,int32 ifile3num, uint32 *premainbyte); + + +/************************************************************* +Description: // ¶¯Ì¬ÏÂÔØ3´ÎÎļþ, Æô¶¯ÏÂÔØ, +Input: //¿¨Á´½Óhandle, + ifile3num 3´ÎÎļþ±àºÅ + File3Name ÉèÖÃ3´ÎÎļþÃû.3´ÎÎļþ²»»á×Ô¶¯ÔËÐÐ, Ðèµ÷ÓÃZAux_Run3FileRamÆô¶¯. +Output: //premainbyte Ê£Óà¿Ø¼þ×Ö½ÚÊý +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_3FileRamDownBegin(ZMC_HANDLE handle, int32 ifile3num,uint32 *premainbyte,char*File3Name); + +/************************************************************* +Description: // ¶¯Ì¬ÏÂÔØ3´ÎÎļþ, Ò»´ÎÏÂÔØµÄÄÚÈݱØÐëÊÇÍêÕûµÄµ¥Ðлò¶àÐÐ,ûÓÐÏÂÔØÍê³Éʱ, ºóÃæÓÃ\n½áβ, È«²¿ÏÂÔØÍê³Éʱ, ¼Ó\0½áβ + \0»áµ¼ÖÂÈÎÎñ½áÊø, ÐèÒªÔÙÆô¶¯¿ÉÒÔÔٴε÷ÓÃZMC_Run3FileRam +Input: //¿¨Á´½Óhandle + ifile3num 3´ÎÎļþ±àºÅ + pbuffer Îļþ×Ö·û´® + buffsize ×Ö·û´®³¤¶È +Output: //premainbyte Ê£Óà¿Ø¼þ×Ö½ÚÊý +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_3FileRamDownPart(ZMC_HANDLE handle,int32 ifile3num, const char* pbuffer, uint32 buffsize, uint32 *premainbyte); + + +/************************************************************* +Description: // ¶¯Ì¬ÏÂÔØ3´ÎÎļþ, ÏÂÔØ½áÊø, ×Ô¶¯¼Ó\0, ²¢ÇÒµ÷ÓúóÔٴε÷Óà ZMC_3FileRamDownPart »á·µ»Ø´íÎó. +Input: //¿¨Á´½Óhandle + ifile3num 3´ÎÎļþ±àºÅ +Output: //Ê£Óà¿Ø¼þ×Ö½ÚÊý +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_3FileRamDownEnd(ZMC_HANDLE handle,int32 ifile3num); + + + +/************************************************************* +Description: //ÏÂÔØ3´ÎÎļþµ½RAM +Input: //¿¨Á´½Óhandle ÎļþÃû, ±ØÐëÊÇ3´ÎÎļþ + pfilename 3´ÎÎļþÃû³Æ + pfilenameinControl Ö§³ÖNCÎļþÃû, ±ØÐë´øÃû³Æ, ·ñÔòÎÞ·¨Çø·Öbas»¹ÊÇNCÎļþ + ifile3num 3´ÎÎļþ±àºÅ +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Down3FileRam(ZMC_HANDLE handle, const char* pfilename, const char* pfilenameinControl,int32 ifile3num); + +/************************************************************* +Description: //ÉèÖò岹Ô˶¯OPÌáǰ¾àÀë +Input: //¿¨Á´½Óhandle + iaxis ÖáºÅ +Output: //fValue ,MOVE_OPÌáǰ¾àÀëÊä³öÉèÖÃ, ȱʡ0²»ÉúЧ, ÉèÖÃÕýÊý»á±ÈȱʡÊä³öλÖÃÌáǰָ¶¨Ê¸Á¿¾àÀë, ÉèÖøºÊýÔòÑÓºóÖ¸¶¨Ê¸Á¿¾àÀë. +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_Direct_SetMoveOPAdist(ZMC_HANDLE handle, int iaxis, float fValue); + +/************************************************************* +Description: //ZPJÏîÄ¿ÎļþÉú³ÉZAR²¢ÇÒÏÂÔØµ½¿ØÖÆÆ÷ÔËÐÐ +Input: //¿¨Á´½Óhandle +Input: //ZpjName ZPJÎļþ·¾¶ +Input: //ZarName ZarÎļþ·¾¶ +Input //pPass Èí¼þÃÜÂë, °ó¶¨APP_PASS ûÓÐÃÜÂëʱpPass = NULL +Input //uid °ó¶¨¿ØÖÆÆ÷ΨһID£¬ 0-²»°ó¶¨ +Input: //ÏÂÔØµ½RAM-ROM 0-RAM 1-ROM +Output: // +Return: //´íÎóÂë +*************************************************************/ +int32 __stdcall ZAux_ZpjDown(ZMC_HANDLE handle,const char *ZpjName,const char *ZarName,const char *pPass, uint32 uid,uint32 run_mode); + + + + +/// +/// ÖáµÄ¼Ó¼õËÙÇúÏßÀàÐÍÉèÖà 20220107Ôö¼Ó. +/// +/// ¾ä±ú +/// Öá±àºÅ +/// [0- ȱʡֵ, ʹÓÃsrampÀ´ÉèÖÃSÇúÏß], +/// [ 6- ÐÂÔöÀàÐÍSSÇúÏß, ¼Ó¼ÓËÙ¶ÈÁ¬ÐøµÄÇúÏßÀàÐÍ,SSģʽ±ÈTÐμõËÙ»áÔö¼Ó87%µÄ¼õËÙʱ¼ä¡£´Ëģʽ¼ÓËÙʱʹÓÃ0ģʽ, ·½±ãÁ¬ÐøÐ¡Ï߶β岹], +/// [7- ÐÂÔöÀàÐÍ,SSÇúÏß, ¼Ó¼ÓËÙ¶ÈÁ¬ÐøµÄÇúÏßÀàÐÍ¡£¶¯Ì¬ÐÞ¸ÄÖá²ÎÊý»òÁ¬Ðø²å²¹¿ÉÄܵ¼Ö¼ӼÓËÙ¶ÈÎÞ·¨Á¬Ðø£¬´Ëʱ»áÇл»µ½0ģʽ£¬Òò´Ë½¨ÒéSRAMPÒ²ÉèÖúÏÊÊÖµ], +/// [4-20220416Ôö¼Ó,Æð²½Ê±×î´ó¼ÓËÙ¶È, ´ïµ½×î¸ßËÙʱ¼ÓËٶȽ¥±äΪ0] +/// +int32 __stdcall ZAux_SetVpMode(ZMC_HANDLE handle, uint axis, uint mode); + + + +/// +/// ÖáµÄ¾«×¼Êä³öopÉèÖà +/// +/// ¾ä±ú +/// ÖáºÅ +/// ģʽ:\ +bit0£º1-VP_SPEEDȱʡʹÓò岹ËÙ¶È£¬0-VP_SPEED ʹÓõ¥ÖáµÄËÙ¶È\ +bit1£º1 - ʹÓÃMOVE_OP¾«È·Êä³ö¹¦ÄÜ£¬ 0 - MOVE_OPÔ­À´µÄ·½Ê½\ +bit4£º1 - ¶Ô´ø±àÂëÆ÷¹¦ÄܵÄÖᣬʹÓñàÂëÆ÷λÖõÄMOVE_OP¾«×¼·½Ê½\ +¶à¸ö±àÂëÆ÷Öá²å²¹Ê±£¬Ê¹ÓÃBASEÔ˶¯Ö÷ÖáµÄÉèÖà +/// +int32 __stdcall ZAux_SetAxisZset(ZMC_HANDLE handle, uint axis, uint mode); + + +/// +/// ¿ØÖÆÆ÷×ÜÏßɨÃè +/// +/// ¾ä±ú +/// ²ÛλºÅ +/// ÃüÁîÔËÐÐÍê³Éºó·µ»ØµÄ×Ö·ûÄÚÈÝ +/// +int32 __stdcall ZAux_BusCmd_SlotScan(ZMC_HANDLE handle,uint slotNumber); + +/// +/// ×ÜÏß¿ªÆô +/// +/// ¾ä±ú +/// ²ÛλºÅ +/// ÃüÁîÔËÐÐÍê³Éºó·µ»ØµÄ×Ö·ûÄÚÈÝ +/// +int32 __stdcall ZAux_BusCmd_SlotStart(ZMC_HANDLE handle, uint slotNumber); + +/// +/// ×ÜÏßÍ£Ö¹ +/// +/// ¾ä±ú +/// ²ÛλºÅ +/// +int32 __stdcall ZAux_BusCmd_SlotStop(ZMC_HANDLE handle, uint slotNumber); + + + + +/// +/// ¶ÁÈ¡¿ØÖÆÏµÍ³¹æ¸ñ +/// +/// ¾ä±ú +/// [0- ×î´óÐéÄâÖáÊý]¡¢[1-Ö§³Öµç»ú¸öÊý]¡¢[2-±¾ÌåÊäÈë¸öÊý]¡¢[3-±¾ÌåÊä³ö¸öÊý]¡¢[4- ±¾ÌåÄ£ÄâÊäÈëAIN]¡¢[5-±¾ÌåÄ£ÄâÊä³öAOUT]¡¢[6-PWM¸öÊý]¡¢ [7- BASICÈÎÎñÊý£¬²»°üº¬ÖжÏÈÎÎñ]¡¢[8-×ÜÏß²ÛλÊý]¡¢[9-3´ÎÎļþ¸öÊý]¡¢[10-´®¿ÚÁ´½ÓÊý]¡¢[11-ÍøÂçÁ´½ÓÊý]¡¢[12-ÍøÂç×Ô¶¨ÒåÁ´½ÓÊý0-²»Ö§³Ö]¡¢ [13-ÍøÂ绥ÁªÖ÷¶ËÊý£¬0-²»Ö§³Ö£¬ £¨´Ó¶Ë×ÜÊÇÖ§³Ö£©]¡¢[14-FLASH¿éÊý]¡¢[15-FLASH¿é´óС]¡¢[16-VR¸öÊý]¡¢ [17-MODBUS_BIT¸öÊý]¡¢[18-MODBUS_REG¸öÊý]¡¢[19-¶¨Ê±Æ÷¸öÊý]¡¢[20-Êý×é¿Õ¼ä]¡¢[21-ÐéÄâ×î´óÊäÈë¸öÊý£¬¶ÔÓ¦PLCµÄX¼Ä´æÆ÷¸öÊý]¡¢[22-ÐéÄâ×î´óÊä³ö¸öÊý£¬¶ÔÓ¦PLCµÄY¼Ä´æÆ÷¸öÊý]¡¢[23-ÐéÄâ×î´óAIN¸öÊý]¡¢[24-ÐéÄâ×î´óAOUT¸öÊý]¡¢[25-PLC¼ÆÊýÆ÷]¡¢[26-PLC S¼Ä´æÆ÷]¡¢[27-PLC V¼Ä´æÆ÷]¡¢[28-PLC Z¼Ä´æÆ÷]¡¢ [29-PLC L¼Ä´æÆ÷]¡¢[30-HMI¸öÊý£¬£¨°üÀ¨ÍøÂçHMIÓë±¾ÌåHMI£©]¡¢[31-±¾Ìå×Ô´øHMI¸öÊý] +/// ¶ÁÈ¡µ½µÄϵͳֵ +/// +int32 __stdcall ZAux_GetSysZfeature(ZMC_HANDLE handle,uint code,uint &value); + + + +/// +/// ×ÜÏßͨѶÖÜÆÚÉèÖà +/// +/// ¾ä±ú +/// Öµ +/// +int32 __stdcall ZAux_SetServoPeriod(ZMC_HANDLE handle,uint value); +/// +/// ×ÜÏßͨѶÖÜÆÚ»ñÈ¡ +/// +/// ¾ä±ú +/// Öµ +/// +int32 __stdcall ZAux_GetServoPeriod(ZMC_HANDLE handle, uint &value); + + +/// +/// É豸µç»úÊý»ñÈ¡ +/// +/// ¾ä±ú +/// ²ÛλºÅ +/// ½Úµã +/// µç»úÊýÁ¿ +/// +int32 __stdcall ZAux_BusCmd_GetNodeAxisCount(ZMC_HANDLE handle,uint slot,uint node,uint&axisNumber); + +/// +/// ÉèÖÃÖáPDO +/// +/// ¾ä±ú +/// Öá +/// pdoģʽ +/// +int32 __stdcall ZAux_BusCmd_SetDriveProfile(ZMC_HANDLE handle,uint axis,int mode); + + + +/// +/// »ñÈ¡ÖáPDOÉèÖà +/// +/// ¾ä±ú +/// Öá +/// ¶ÁÈ¡µÄģʽ +/// +int32 __stdcall ZAux_BusCmd_GetDriveProfile(ZMC_HANDLE handle, uint axis,int & mode); + + +/// +/// Ó³ÉäÇý¶¯Æ÷IoµÄÆðʼ±àºÅ +/// +/// ¾ä±ú +/// Öá +/// io³õʼ±àºÅ +/// +int32 __stdcall ZAux_BusCmd_SetDriveIO(ZMC_HANDLE handle, uint axis,uint IoStart); + +/// +/// »ñȡӳÉäµÄÇý¶¯Æ÷IoµÄÆðʼ±àºÅ +/// +/// ¾ä±ú +/// Öá +/// io³õʼ±àºÅ +/// +int32 __stdcall ZAux_BusCmd_GetDriveIO(ZMC_HANDLE handle, uint axis, uint &IoStart); + +/// +/// ÉèÖýڵãÉ豸µÄIO³õʼλÖà +/// +/// ¾ä±ú +/// ²Û +/// ½Úµã +/// ioÆðʼ±àºÅ +/// +int32 __stdcall ZAux_BusCmd_SetNodeIo(ZMC_HANDLE handle, uint slot, uint node, uint IoStart); + + +/// +/// »ñÈ¡½ÚµãÉ豸µÄIO³õʼλÖà +/// +/// ¾ä±ú +/// ²Û +/// ½Úµã +/// ioÆðʼ±àºÅ +/// +int32 __stdcall ZAux_BusCmd_GetNodeIo(ZMC_HANDLE handle, uint slot, uint node, uint &IoStart); + + +/// +/// Öá·Ö×é һͣ¶à£¬°Ñ¼¸¸öÖáÉèÖÃΪһ×飬Çý¶¯Æ÷¸æ¾¯ºó»á¹Ø±Õ×éÄÚµÄËùÓÐʹÄÜ(ECAT²úÆ·Ö§³Ö)£¬Âö³åÖáÉèÖÃÎÞÒâÒå¡£ +/// +/// ¾ä±ú +/// ÖáÊý +/// ÖáÁбíÊý×é +/// +int32 __stdcall ZAux_Direct_DisableGroup(ZMC_HANDLE handle,uint axisNumber ,uint* axisList); + + +/// +/// ¿ØÖÆËùÓÐ×ÜÏßÖáµÄʹÄÜ¡£Ê¹ÓÃEtherCAT×ÜÏßʱ£¬±ØÐëʹWDOG = 1¡£ +/// +/// ¾ä±ú +/// ÊÇ·ñʹÄÜ +/// +int32 __stdcall ZAux_Direct_SetWdog(ZMC_HANDLE handle, bool b); + +/// +/// »ñÈ¡ËùÓÐ×ÜÏßÖáµÄʹÄÜ +/// +/// ¾ä±ú +/// ÊÇ·ñʹÄÜ +/// +int32 __stdcall ZAux_Direct_GetWdog(ZMC_HANDLE handle, bool &b); + + +/// +/// Ô˶¯Öеþ¼Ó +/// +/// ¾ä±ú +/// Ö÷Öá +/// µþ¼ÓÖáµÄÊýÁ¿ +/// µþ¼ÓÖáµÄÁбí +/// +int32 __stdcall ZAux_Direct_MoveAddax(ZMC_HANDLE handle, int baseAxis, uint iaxisNumber, int* iaddaxisList); + + + + +/// +/// ¿Õ¼äÔ²»¡¾ø¶ÔÔ˶¯ + ÂÝÐý²å²¹Ô˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +/// +/// ¿¨Á´½Óhandle +/// ²ÎÓëÔ˶¯×ÜÖáÊý +/// ÖáºÅÁбí +/// µÚ1¸öÖáÔ˶¯¾àÀë²ÎÊý1 ¾ø¶ÔλÖà +/// µÚ2¸öÖáÔ˶¯¾àÀë²ÎÊý1 ¾ø¶ÔλÖà +/// µÚ3¸öÖáÔ˶¯¾àÀë²ÎÊý1 ¾ø¶ÔλÖà +/// µÚ1¸öÖáÔ˶¯¾àÀë²ÎÊý2 ¾ø¶ÔλÖà +/// µÚ2¸öÖáÔ˶¯¾àÀë²ÎÊý2 ¾ø¶ÔλÖà +/// µÚ3¸öÖáÔ˶¯¾àÀë²ÎÊý2 ¾ø¶ÔλÖà +/// Ö¸¶¨Ç°Ãæ²ÎÊýµÄÒâÒå 0 µ±Ç°µã£¬Öмäµã£¬ÖÕµãÈýµã¶¨Ô²»¡£¬¾àÀë²ÎÊý1ΪÖÕµã¾ø¶ÔλÖ㬾àÀë²ÎÊý2ΪÖмäµã¾ø¶ÔλÖá£\ + 1 ×ß×îСµÄÔ²»¡£¬¾àÀë²ÎÊý1ΪÖÕµã¾àÀ룬¾àÀë²ÎÊý2ΪԲÐĵľø¶ÔλÖá£\ + 2 µ±Ç°µã£¬Öмäµã£¬ÖÕµãÈýµã¶¨Ô²£¬¾àÀë²ÎÊý1ΪÖÕµã¾ø¶ÔλÖ㬾àÀë²ÎÊý2ΪÖмäµã¾ø¶ÔλÖá£\ + 3 ÏÈ×ß×îСµÄÔ²»¡£¬ÔÙ¼ÌÐø×ßÍêÕûÔ²£¬¾àÀë²ÎÊý1ΪÖÕµã¾ø¶ÔλÖ㬾àÀë²ÎÊý2ΪԲÐĵľø¶ÔλÖᣠ+/// µÚ4¸öÖáÔ˶¯¾ø¶ÔλÖà +/// µÚ5¸öÖáÔ˶¯¾ø¶ÔλÖà +/// ´íÎóÂë +int32 __stdcall ZAux_Direct_MSphericalAbs(ZMC_HANDLE handle, int imaxaxises, int* piAxislist, float fend1, float fend2, float fend3, float fcenter1, float fcenter2, float fcenter3, int imode, float fcenter4, float fcenter5); + + +/// +/// //¿Õ¼äÔ²»¡¾ø¶Ô + ÂÝÐý ²å²¹SPÔ˶¯ 20130901 ÒÔºóµÄ¿ØÖÆÆ÷°æ±¾Ö§³Ö +/// +/// ¿¨Á´½Óhandle +/// ²ÎÓëÔ˶¯×ÜÖáÊý +/// ÖáºÅÁбí +/// µÚ1¸öÖáÔ˶¯¾àÀë²ÎÊý1 ¾ø¶ÔλÖà +/// µÚ2¸öÖáÔ˶¯¾àÀë²ÎÊý1 ¾ø¶ÔλÖà +/// µÚ3¸öÖáÔ˶¯¾àÀë²ÎÊý1 ¾ø¶ÔλÖà +/// µÚ1¸öÖáÔ˶¯¾àÀë²ÎÊý2 ¾ø¶ÔλÖà +/// µÚ2¸öÖáÔ˶¯¾àÀë²ÎÊý2 ¾ø¶ÔλÖà +/// µÚ3¸öÖáÔ˶¯¾àÀë²ÎÊý2 ¾ø¶ÔλÖà +/// Ö¸¶¨Ç°Ãæ²ÎÊýµÄÒâÒå:0 µ±Ç°µã£¬Öмäµã£¬ÖÕµãÈýµã¶¨Ô²»¡£¬¾àÀë²ÎÊý1ΪÖÕµã¾ø¶ÔλÖ㬾àÀë²ÎÊý2ΪÖмäµã¾ø¶ÔλÖá£\ + 1 ×ß×îСµÄÔ²»¡£¬¾àÀë²ÎÊý1ΪÖÕµã¾ø¶ÔλÖ㬾àÀë²ÎÊý2ΪԲÐĵľø¶ÔλÖá£\ + 2 µ±Ç°µã£¬Öмäµã£¬ÖÕµãÈýµã¶¨Ô²£¬¾àÀë²ÎÊý1ΪÖÕµã¾ø¶ÔλÖ㬾àÀë²ÎÊý2ΪÖмäµã¾ø¶ÔλÖá£\ + 3 ÏÈ×ß×îСµÄÔ²»¡£¬ÔÙ¼ÌÐø×ßÍêÕûÔ²£¬¾àÀë²ÎÊý1ΪÖÕµã¾ø¶ÔλÖ㬾àÀë²ÎÊý2ΪԲÐĵľø¶ÔλÖᣠ+/// µÚ4¸öÖáÔ˶¯¾àÀë²ÎÊý ¾ø¶ÔλÖà +/// µÚ5¸öÖáÔ˶¯¾àÀë²ÎÊý ¾ø¶ÔλÖà +/// +int32 __stdcall ZAux_Direct_MSphericalAbsSp(ZMC_HANDLE handle, int imaxaxises, int* piAxislist, float fend1, float fend2, float fend3, float fcenter1, float fcenter2, float fcenter3, int imode, float fcenter4, float fcenter5); + + + + +/// +/// ¿ªÆôÒ»¸öÈÎÎñÔËÐÐzbaisc¶¨ÒåµÄº¯Êý£¬ÐèÒªÓõ½¿ØÖÆÆ÷/¿¨ ±¾ÉíµÄÈÎÎñ±àºÅ +/// +/// ¾ä±ú +/// ¿ØÖÆÆ÷/¿¨µÄÈÎÎñ±àºÅ +/// zbasic¶¨ÒåµÄº¯ÊýÃû³Æ¿ÉÒÔ´ø²ÎÊý£¬Àý test»òtest(1,2,3)»òtest(zbasicÈ«¾Ö±äÁ¿) +/// ´íÎóÂë +int32 __stdcall ZAux_RunTask(ZMC_HANDLE handle, int taskNumber, const char* BasicFunctionName); + + + +/// +/// Í£Ö¹Ò»¸ö¿ØÖÆÆ÷/¿¨µÄÈÎÎñ +/// +/// ¾ä±ú +/// ¿ØÖÆÆ÷/¿¨µÄÈÎÎñ±àºÅ +/// ´íÎóÂë +int32 __stdcall ZAux_Direct_StopTask(ZMC_HANDLE handle, int taskNumber); + + + +/// +/// »ñÈ¡¿ØÖÆÆ÷/¿¨µÄÈÎÎñ״̬ +/// +/// ¾ä±ú +/// ¿ØÖÆÆ÷/¿¨µÄÈÎÎñ±àºÅ +/// ״̬·µ»ØÖµ 0£ºÈÎÎñÍ£Ö¹ 1:ÈÎÎñÕýÔÚÔËÐÐ 3£ºÈÎÎñÔÝÍ£ÖÐ +/// ´íÎóÂë +int32 __stdcall ZAux_Direct_GetProcStatus(ZMC_HANDLE handle, int taskNumber,int& status); + +/// +/// »Ö¸´¿ØÖÆÆ÷/¿¨µÄÖ¸¶¨ÈÎÎñÔËÐÐ +/// +/// ¾ä±ú +/// ¿ØÖÆÆ÷/¿¨µÄÈÎÎñ±àºÅ +/// +int32 __stdcall ZAux_ResumeTask(ZMC_HANDLE handle, int taskNumber); + +/// +/// ÔÝÍ£¿ØÖÆÆ÷/¿¨µÄÖ¸¶¨ÈÎÎñÔËÐÐ +/// +/// ¾ä±ú +/// ¿ØÖÆÆ÷/¿¨µÄÈÎÎñ±àºÅ +/// +int32 __stdcall ZAux_PauseTask(ZMC_HANDLE handle, int taskNumber); +#ifdef __cplusplus +} +#endif + + +#endif + + + + + + + + + + + diff --git a/shared/resources/zmotion/x64/zmotion.dll b/shared/resources/zmotion/x64/zmotion.dll new file mode 100644 index 0000000..2a21a1e Binary files /dev/null and b/shared/resources/zmotion/x64/zmotion.dll differ diff --git a/shared/src/orisys_app_shared/acquisition/arrows.py b/shared/src/orisys_app_shared/acquisition/arrows.py index b2380b7..14714e6 100644 --- a/shared/src/orisys_app_shared/acquisition/arrows.py +++ b/shared/src/orisys_app_shared/acquisition/arrows.py @@ -125,6 +125,72 @@ def _ensure_bgr(img: np.ndarray) -> np.ndarray: return img +def _policy_from_style( + draw_style: str, + *, + flow_on_px: float | None = None, + flow_off_px: float | None = None, + flow_grid_spacing: int | None = None, + max_arrows: int | None = None, +): + from orisys_app_shared.scene3d.visual_policy import VisualPolicy + + finger = str(draw_style).lower().startswith("f") + defaults = _FINGER_ARROW_DRAW_KW if finger else _ARROW_DRAW_KW + return VisualPolicy( + flow_on_px=float(defaults["threshold"] if flow_on_px is None else flow_on_px), + flow_off_px=float( + (0.3 if finger else max(0.0, float(defaults["threshold"]) * 0.75)) + if flow_off_px is None + else flow_off_px + ), + flow_grid_spacing=int( + defaults["grid_spacing"] if flow_grid_spacing is None else flow_grid_spacing + ), + flow_arrow_scale=float(defaults["arrow_scale"]), + flow_edge_margin=8 if finger else 15, + max_arrows=400 if max_arrows is None else int(max_arrows), + ).normalised() + + +def _paint_shared_flow_arrows( + canvas: np.ndarray, + flow: np.ndarray, + *, + valid_mask: Optional[np.ndarray], + policy, + colormap: Optional[np.ndarray], + below_threshold_color: tuple[int, int, int], +) -> np.ndarray: + """Draw arrows from :func:`select_flow_samples` so 2D matches 3D sample points.""" + from orisys_app_shared.scene3d.visual_policy import select_flow_samples + + image = canvas.copy() + samples = select_flow_samples(flow, valid_mask, policy) + if samples.rows.size == 0: + return image + mag = samples.magnitude + mag_range = float(mag.max() - mag.min()) if mag.size else 0.0 + if mag_range > 0: + norm = ((mag - mag.min()) / mag_range * 255.0).astype(np.uint8) + else: + norm = np.zeros(mag.shape, dtype=np.uint8) + cmap = cv2.COLORMAP_JET if colormap is None else colormap + colors = cv2.applyColorMap(norm.reshape(-1, 1), cmap) + for i in range(samples.rows.size): + pt1 = (int(samples.cols[i]), int(samples.rows[i])) + pt2 = ( + int(round(samples.cols[i] + samples.dx[i])), + int(round(samples.rows[i] + samples.dy[i])), + ) + color = tuple(int(c) for c in colors[i, 0]) + if mag[i] < policy.flow_on_px: + cv2.circle(image, pt1, radius=1, color=below_threshold_color, thickness=-1) + else: + cv2.arrowedLine(image, pt1, pt2, color, thickness=1, tipLength=0.2) + return image + + def draw_arrow_display( processed_frame: np.ndarray, flow: Optional[np.ndarray], @@ -134,25 +200,81 @@ def draw_arrow_display( draw_style: str = "box", valid_mask: Optional[np.ndarray] = None, colormap: Optional[np.ndarray] = None, + exterior_bgr: Sequence[int] | None = None, + island_bgr: Sequence[int] | None = None, + flow_on_px: float | None = None, + flow_off_px: float | None = None, + flow_grid_spacing: int | None = None, + max_arrows: int | None = None, ) -> np.ndarray: - """Draw flow arrows on a solid canvas or over the processed frame.""" + """Draw flow arrows on a solid canvas or over the processed frame. + + For ``pure_color``, the UV ``valid_mask`` paints the finger island in + ``island_bgr`` and the exterior in ``exterior_bgr`` (defaults: theme canvas + inside, slightly darker outside). Without a mask the whole rectangle uses + ``canvas_bgr`` / ``island_bgr`` for backward compatibility. + + Finger style uses the shared :mod:`visual_policy` sampler so 2D and 3D arrows + share the same UV sample points and thresholds. Box style keeps the SDK path. + """ base = _ensure_bgr(processed_frame) if flow is None: return base - draw_kw = dict(_FINGER_ARROW_DRAW_KW if str(draw_style).lower().startswith("f") else _ARROW_DRAW_KW) + finger = str(draw_style).lower().startswith("f") + draw_kw = dict(_FINGER_ARROW_DRAW_KW if finger else _ARROW_DRAW_KW) if colormap is not None: draw_kw["colormap"] = colormap try: + if background == "pure_color": + color_bgr = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr) + inside = color_bgr if island_bgr is None else tuple(int(v) for v in island_bgr) + outside = ( + tuple(max(0, int(v) - 12) for v in inside) + if exterior_bgr is None + else tuple(int(v) for v in exterior_bgr) + ) + if valid_mask is not None: + from orisys_app_shared.scene3d.visual_policy import ( + build_domain_mask, + paint_domain_background, + ) + + domain, _ = build_domain_mask( + valid_mask, shape_hw=base.shape[:2] + ) + canvas = paint_domain_background( + base.shape[:2], + domain, + island_bgr=inside, # type: ignore[arg-type] + exterior_bgr=outside, # type: ignore[arg-type] + ) + valid_mask = domain + else: + canvas = np.empty_like(base) + canvas[:, :] = inside + else: + canvas = base + + if finger: + policy = _policy_from_style( + draw_style, + flow_on_px=flow_on_px, + flow_off_px=flow_off_px, + flow_grid_spacing=flow_grid_spacing, + max_arrows=max_arrows, + ) + return _paint_shared_flow_arrows( + canvas, + np.asarray(flow), + valid_mask=valid_mask, + policy=policy, + colormap=colormap, + below_threshold_color=tuple(draw_kw["below_threshold_color"]), + ) + import orisys - if background == "pure_color": - canvas = np.empty_like(base) - color_bgr = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr) - canvas[:, :] = color_bgr - return orisys.util.draw_arrows( - canvas, flow, valid_mask=valid_mask, **draw_kw - ) - return orisys.util.draw_arrows(base, flow, valid_mask=valid_mask, **draw_kw) + return orisys.util.draw_arrows(canvas, flow, valid_mask=valid_mask, **draw_kw) except Exception: return base diff --git a/shared/src/orisys_app_shared/acquisition/finger_worker.py b/shared/src/orisys_app_shared/acquisition/finger_worker.py index 9cc0c84..66d26d8 100644 --- a/shared/src/orisys_app_shared/acquisition/finger_worker.py +++ b/shared/src/orisys_app_shared/acquisition/finger_worker.py @@ -14,6 +14,7 @@ from orisys_app_shared.acquisition.sinks import RecordSinkMixin # The packet and the geometry come from ``models``, so the finger worker does not # depend on the box worker; only genuinely shared helpers are imported from it. from orisys_app_shared.acquisition.models import ( + ContactObservation, FixedCameraMode, SdkPacket, ensure_bgr as _ensure_bgr, @@ -539,6 +540,11 @@ class FingerAcquisitionWorker(RecordSinkMixin): depth_valid = (depth is not None) and not compute_error if depth_valid: last_depth_frame_id = frame_id + contact = _contact_from_sensor( + tactile, + frame_id=frame_id, + compute_error=compute_error, + ) packet = SdkPacket( raw_frame=raw_frame, processed_frame=_ensure_bgr(unrolled), @@ -573,6 +579,7 @@ class FingerAcquisitionWorker(RecordSinkMixin): depth_valid=depth_valid, depth_frame_id=frame_id if depth_valid else last_depth_frame_id, forces_valid=bool(forces.get("valid", True)) and not compute_error, + contact=contact, ) with self._lock: self._latest = packet @@ -593,3 +600,30 @@ class FingerAcquisitionWorker(RecordSinkMixin): if self._stop.is_set(): self._set_end_reason("stopped") self._status("Finger acquisition stopped") + + +def _contact_from_sensor(sensor, *, frame_id: int, compute_error: str) -> ContactObservation: + """Copy the SDK contact centre after ``compute_contact``; never invent one.""" + if compute_error: + return ContactObservation(found=False, valid=False, frame_id=int(frame_id)) + found = bool(getattr(sensor, "centroid_found", False)) + contact_ok = bool(getattr(sensor, "compute_contact_valid", True)) + centroid = getattr(sensor, "centroid", None) + xy: tuple[float, float] | None = None + if found and centroid is not None: + try: + arr = np.asarray(centroid, dtype=np.float64).reshape(-1) + if arr.size >= 2 and np.all(np.isfinite(arr[:2])): + xy = (float(arr[0]), float(arr[1])) + else: + found = False + except Exception: # noqa: BLE001 + found = False + xy = None + return ContactObservation( + centroid_uv_xy=xy, + found=bool(found and xy is not None), + valid=bool(contact_ok and found and xy is not None), + frame_id=int(frame_id), + domain_id="", + ) diff --git a/shared/src/orisys_app_shared/acquisition/image_backend.py b/shared/src/orisys_app_shared/acquisition/image_backend.py index 4cf7e9c..67402a3 100644 --- a/shared/src/orisys_app_shared/acquisition/image_backend.py +++ b/shared/src/orisys_app_shared/acquisition/image_backend.py @@ -83,12 +83,25 @@ class ImageFrame: #: True when the producer knows this frame's measurement is not current (the #: coordinator's "stale" state); the panel can say so under the picture. stale: bool = False + #: Per-field provenance mirroring ``SdkPacket``; ``None`` means unstated. + flow_valid: Optional[bool] = None + flow_frame_id: Optional[int] = None + depth_valid: Optional[bool] = None + depth_frame_id: Optional[int] = None def has_flow(self) -> bool: - return self.flow is not None + if self.flow is None: + return False + if self.flow_valid is False: + return False + return True def has_depth(self) -> bool: - return self.depth is not None + if self.depth is None: + return False + if self.depth_valid is False: + return False + return True @dataclass(frozen=True) @@ -195,6 +208,8 @@ def render_image( if wanted == DEPTH: # Never substitute another picture for a missing depth map: that made "no # depth" look like a depth reading. The panel says so instead. + if frame.depth_valid is False: + return None return depth_map_to_bgr( frame.depth, flow_colormap(theme), @@ -204,7 +219,7 @@ def render_image( if frame.image_bgr is None: return None if wanted in (ARROW_PURE, ARROW_STITCHED): - if frame.flow is None: + if frame.flow is None or frame.flow_valid is False: # The base picture with no arrows, and the plan's ``missing`` says why. return ensure_bgr(np.asarray(frame.image_bgr)) return draw_arrow_display( diff --git a/shared/src/orisys_app_shared/acquisition/models.py b/shared/src/orisys_app_shared/acquisition/models.py index ea4e7f0..69a8284 100644 --- a/shared/src/orisys_app_shared/acquisition/models.py +++ b/shared/src/orisys_app_shared/acquisition/models.py @@ -32,6 +32,22 @@ class FixedCameraMode: return f"{self.width}x{self.height} @{self.fps:g}" +@dataclass(frozen=True) +class ContactObservation: + """Contact centre in the UV domain, copied from the SDK after ``compute_contact``. + + Units are UV pixels with ``(x, y)`` order. Missing or failed contact is + ``found=False`` / ``valid=False`` — never a fabricated centre. Old recordings + without this field do not invent a yellow contact arrow. + """ + + centroid_uv_xy: Optional[tuple[float, float]] = None + found: bool = False + valid: bool = False + frame_id: int = 0 + domain_id: str = "" + + @dataclass class SdkPacket: """One acquired frame: the images, the force readout and its provenance.""" @@ -103,6 +119,8 @@ class SdkPacket: #: Whether the force triplet belongs to this frame (False when the chain or the #: readout failed, whatever ``valid`` says about the record). forces_valid: Optional[bool] = None + #: Contact centre from ``compute_contact``; optional so old call sites stay valid. + contact: Optional[ContactObservation] = None def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode: diff --git a/shared/src/orisys_app_shared/force/__init__.py b/shared/src/orisys_app_shared/force/__init__.py index 2509037..6e05f60 100644 --- a/shared/src/orisys_app_shared/force/__init__.py +++ b/shared/src/orisys_app_shared/force/__init__.py @@ -30,6 +30,11 @@ from orisys_app_shared.force.session import ( summarise, ) from orisys_app_shared.force.units import DisplayForces, select_display_forces +from orisys_app_shared.force.presentation import ( + DisplayForce, + ForcePresentation, + ZeroEstimate, +) __all__ = [ "CHANNEL_KEYS", @@ -39,15 +44,18 @@ __all__ = [ "CalSessionStore", "CalibrationPoint", "CalibrationRecorder", + "DisplayForce", "DisplayForces", "FilterSettings", "ForceFilterChain", + "ForcePresentation", "MetricHistory", "PlotState", "SelectionStats", "SessionSample", "SessionSlice", "SessionStore", + "ZeroEstimate", "build_force_calibration_v2", "describe_state", "effective_filters", diff --git a/shared/src/orisys_app_shared/force/presentation.py b/shared/src/orisys_app_shared/force/presentation.py new file mode 100644 index 0000000..776d2c5 --- /dev/null +++ b/shared/src/orisys_app_shared/force/presentation.py @@ -0,0 +1,288 @@ +"""Display-only force presentation: baseline, deadband, and formatting. + +Never mutates recorded samples. Curves, statistics and CSV keep the measurement +values; value cards (and optional display copies) go through this layer. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional, Sequence + +import numpy as np + + +@dataclass(frozen=True) +class ZeroEstimate: + """Outcome of an idle-window bias / noise estimate.""" + + offsets: tuple[float, float, float] + deadbands: tuple[float, float, float] + sample_count: int + quality: str # "ok" | "insufficient" | "unstable" | "contaminated" + message: str = "" + + +@dataclass(frozen=True) +class DisplayForce: + """One displayed triplet after offset + deadband (measurement unchanged).""" + + values: tuple[float, float, float] + suppressed: tuple[bool, bool, bool] + unit: str + zero_zone: bool + + +@dataclass +class ForcePresentation: + """Stateful display policy for force value cards. + + Startup auto-baseline collects quiet samples, freezes offsets and deadbands, + then applies a symmetric hysteretic deadband only to the display copy. + """ + + enabled: bool = True + unit: str = "raw" + baseline_seconds: float = 3.0 + min_samples: int = 30 + max_flow_p95: float = 0.4 + require_no_contact: bool = True + #: Residual percentile used as the per-axis deadband after offset removal. + deadband_percentile: float = 99.0 + #: Extra margin on top of the percentile residual. + deadband_margin: float = 1.05 + #: Floor so tiny MAD windows do not produce a zero deadband. + min_deadband: float = 50.0 + hysteresis_ratio: float = 0.8 + raw_significant_digits: int = 4 + newton_decimals: int = 3 + + _collecting: bool = field(default=True, init=False, repr=False) + _samples: list[tuple[float, float, float]] = field( + default_factory=list, init=False, repr=False + ) + _sample_times: list[float] = field(default_factory=list, init=False, repr=False) + _offsets: Optional[tuple[float, float, float]] = field( + default=None, init=False, repr=False + ) + _deadbands: Optional[tuple[float, float, float]] = field( + default=None, init=False, repr=False + ) + _suppressed: tuple[bool, bool, bool] = field( + default=(False, False, False), init=False, repr=False + ) + _last_estimate: Optional[ZeroEstimate] = field( + default=None, init=False, repr=False + ) + _t0: Optional[float] = field(default=None, init=False, repr=False) + + def reset_session(self, *, unit: str = "raw") -> None: + """Start a new idle estimate for a session.""" + self.unit = "N" if str(unit).upper().startswith("N") else "raw" + self._collecting = self.enabled and self.unit == "raw" + self._samples = [] + self._sample_times = [] + self._offsets = None + self._deadbands = None + self._suppressed = (False, False, False) + self._last_estimate = None + self._t0 = None + + def request_reestimate(self) -> None: + """Operator-triggered restart of the idle window.""" + self.reset_session(unit=self.unit) + + @property + def ready(self) -> bool: + return self._offsets is not None and self._deadbands is not None + + @property + def last_estimate(self) -> Optional[ZeroEstimate]: + return self._last_estimate + + def observe( + self, + values: Sequence[float], + *, + t_rel_s: float, + valid: bool = True, + flow_p95: Optional[float] = None, + contact_found: bool = False, + ) -> Optional[ZeroEstimate]: + """Feed one live sample into the startup estimator. + + Returns a :class:`ZeroEstimate` when the window closes (ok or failed). + """ + if not self._collecting or self.unit != "raw": + return None + if self._t0 is None: + self._t0 = float(t_rel_s) + if not valid: + return None + if self.require_no_contact and contact_found: + # Contaminated window — keep collecting but mark later if needed. + return None + if flow_p95 is not None and float(flow_p95) > self.max_flow_p95: + return None + sample = tuple(float(v) for v in values[:3]) + if len(sample) < 3 or not all(np.isfinite(sample)): + return None + self._samples.append(sample) # type: ignore[arg-type] + self._sample_times.append(float(t_rel_s)) + elapsed = float(t_rel_s) - float(self._t0) + if elapsed < self.baseline_seconds and len(self._samples) < self.min_samples * 2: + return None + if len(self._samples) < self.min_samples: + estimate = ZeroEstimate( + offsets=(0.0, 0.0, 0.0), + deadbands=(0.0, 0.0, 0.0), + sample_count=len(self._samples), + quality="insufficient", + message=f"only {len(self._samples)} quiet samples in {elapsed:.2f}s", + ) + self._last_estimate = estimate + self._collecting = False + return estimate + estimate = self.fit_zero(self._samples) + self._last_estimate = estimate + self._collecting = False + if estimate.quality == "ok": + self._offsets = estimate.offsets + self._deadbands = estimate.deadbands + return estimate + + def fit_zero(self, samples: Sequence[Sequence[float]]) -> ZeroEstimate: + """Estimate offsets and deadbands from quiet samples (does not apply them).""" + block = np.asarray(samples, dtype=np.float64) + if block.ndim != 2 or block.shape[1] < 3 or block.shape[0] < 3: + return ZeroEstimate( + offsets=(0.0, 0.0, 0.0), + deadbands=(0.0, 0.0, 0.0), + sample_count=int(block.shape[0]) if block.ndim == 2 else 0, + quality="insufficient", + message="not enough samples to estimate a baseline", + ) + offsets: list[float] = [] + deadbands: list[float] = [] + for axis in range(3): + col = block[:, axis] + finite = col[np.isfinite(col)] + if finite.size < 3: + return ZeroEstimate( + offsets=(0.0, 0.0, 0.0), + deadbands=(0.0, 0.0, 0.0), + sample_count=int(block.shape[0]), + quality="insufficient", + message=f"axis {axis} lacks finite samples", + ) + med = float(np.median(finite)) + residual = np.abs(finite - med) + p = float(np.percentile(residual, self.deadband_percentile)) + db = max(self.min_deadband, p * self.deadband_margin) + # Reject wildly unstable windows (p99 >> median magnitude scale). + mad = float(np.median(residual)) + if mad > 0 and p > 20.0 * mad and p > 2000.0: + return ZeroEstimate( + offsets=(0.0, 0.0, 0.0), + deadbands=(0.0, 0.0, 0.0), + sample_count=int(block.shape[0]), + quality="unstable", + message=f"axis {axis} residual p{self.deadband_percentile:g}={p:.1f}", + ) + offsets.append(med) + deadbands.append(db) + return ZeroEstimate( + offsets=(offsets[0], offsets[1], offsets[2]), + deadbands=(deadbands[0], deadbands[1], deadbands[2]), + sample_count=int(block.shape[0]), + quality="ok", + ) + + def apply_estimate(self, estimate: ZeroEstimate) -> None: + """Explicitly freeze an estimate (e.g. after user confirmation).""" + if estimate.quality != "ok": + return + self._offsets = estimate.offsets + self._deadbands = estimate.deadbands + self._collecting = False + self._last_estimate = estimate + + def accept( + self, + values: Sequence[float], + *, + unit: Optional[str] = None, + available: tuple[bool, bool, bool] = (True, True, True), + ) -> DisplayForce: + """Return a display copy; never mutates ``values``.""" + unit = self.unit if unit is None else ( + "N" if str(unit).upper().startswith("N") else "raw" + ) + raw = [float(v) for v in values[:3]] + while len(raw) < 3: + raw.append(float("nan")) + if ( + not self.enabled + or unit != "raw" + or self._offsets is None + or self._deadbands is None + ): + return DisplayForce( + values=(raw[0], raw[1], raw[2]), + suppressed=(False, False, False), + unit=unit, + zero_zone=False, + ) + + out: list[float] = [] + suppressed: list[bool] = [] + prev = self._suppressed + for i in range(3): + if not available[i] or not np.isfinite(raw[i]): + out.append(raw[i]) + suppressed.append(False) + continue + centered = raw[i] - self._offsets[i] + on = self._deadbands[i] + off = on * self.hysteresis_ratio + was = prev[i] + if was: + quiet = abs(centered) <= on + else: + quiet = abs(centered) <= off + if quiet: + out.append(0.0) + suppressed.append(True) + else: + out.append(centered) + suppressed.append(False) + self._suppressed = (suppressed[0], suppressed[1], suppressed[2]) + return DisplayForce( + values=(out[0], out[1], out[2]), + suppressed=self._suppressed, + unit=unit, + zero_zone=any(self._suppressed), + ) + + def format_force(self, value: float, unit: Optional[str] = None) -> str: + """Stable digit formatting for value cards.""" + unit = self.unit if unit is None else ( + "N" if str(unit).upper().startswith("N") else "raw" + ) + if not np.isfinite(value): + return "—" + # Avoid signed zero after deadband. + if value == 0.0 or value == -0.0: + value = 0.0 + if unit == "N": + return f"{value:.{self.newton_decimals}f}" + digits = max(2, int(self.raw_significant_digits)) + abs_v = abs(value) + if abs_v >= 1000: + return f"{value:.0f}" + if abs_v >= 100: + return f"{value:.1f}" + if abs_v >= 10: + return f"{value:.2f}" + # Significant figures for small residual readings. + return f"{value:.{digits}g}" diff --git a/shared/src/orisys_app_shared/paths.py b/shared/src/orisys_app_shared/paths.py index bc1ec62..480394f 100644 --- a/shared/src/orisys_app_shared/paths.py +++ b/shared/src/orisys_app_shared/paths.py @@ -1,34 +1,80 @@ -"""Resolve app, config, and output paths for editable and frozen runs.""" +"""Resolve app, config, and output paths for editable and frozen runs. + +Several desktop products share this package. Which one a process belongs to is +decided once, at start-up: + +* ``ORISYS_APP_ID`` — the directory name under ``apps/`` (e.g. ``studio``, + ``autocal``). Set it **before** importing anything from this package; an app + that changes it later gets whichever root was resolved first, because these + helpers are called during import of logging/prefs. +* Otherwise the historic behaviour applies, which resolves ``apps/studio``. + That keeps existing callers working without them passing anything. +""" from __future__ import annotations +import os import sys from pathlib import Path +#: Environment variable naming the app directory under ``apps/``. +APP_ID_ENV = "ORISYS_APP_ID" + +#: Fallback app when ``ORISYS_APP_ID`` is unset — the first product built here. +DEFAULT_APP_ID = "studio" + def is_frozen() -> bool: return bool(getattr(sys, "frozen", False)) -def _studio_root_from_shared() -> Path: - """Repo layout: shared/src/orisys_app_shared -> apps/studio.""" +def app_id() -> str: + """The app directory name for this process.""" + return os.environ.get(APP_ID_ENV, "").strip() or DEFAULT_APP_ID + + +def apps_dir() -> Path: + """The ``apps/`` directory of the source tree.""" here = Path(__file__).resolve() - # .../orisys-app/shared/src/orisys_app_shared/paths.py + # .../orisys-app/shared/src/orisys_app_shared/paths.py -> .../orisys-app repo_root = here.parents[3] - return repo_root / "apps" / "studio" + return repo_root / "apps" + + +def _default_app_root_from_shared(app_id_value: str) -> Path: + """Repo layout: shared/src/orisys_app_shared -> apps/.""" + return apps_dir() / app_id_value def app_root() -> Path: - """Return the studio app root (editable) or EXE directory (frozen).""" + """Return this app's root (editable) or the EXE directory (frozen).""" if is_frozen(): return Path(sys.executable).resolve().parent - # Prefer cwd override when launched from apps/studio + + wanted = app_id() + + # Launched from inside an app directory (``python apps//run.py``). cwd = Path.cwd().resolve() if (cwd / "config").is_dir() and (cwd / "run.py").is_file(): return cwd - if (cwd / "apps" / "studio" / "config").is_dir(): - return cwd / "apps" / "studio" - return _studio_root_from_shared() + + # Launched from the repo root. + candidate = cwd / "apps" / wanted + if (candidate / "config").is_dir(): + return candidate + + # A named app that is not where we expected must not silently fall back to + # another product's config: that would write one app's preferences into + # another's tree. + if wanted == DEFAULT_APP_ID: + return _default_app_root_from_shared(wanted) + unresolved = _default_app_root_from_shared(wanted) + if (unresolved / "config").is_dir(): + return unresolved + raise FileNotFoundError( + f"{APP_ID_ENV}={wanted!r} does not name an app with a config directory " + f"(looked in {cwd / 'apps' / wanted} and {unresolved})" + ) def config_dir() -> Path: diff --git a/shared/src/orisys_app_shared/prefs/store.py b/shared/src/orisys_app_shared/prefs/store.py index e36bf8f..8c4321a 100644 --- a/shared/src/orisys_app_shared/prefs/store.py +++ b/shared/src/orisys_app_shared/prefs/store.py @@ -1,4 +1,10 @@ -"""Load/save preference.json with defaults merge.""" +"""Load/save preference.json with defaults merge. + +The default schema here is the **studio's**. A second product passes its own +schema to :func:`load_preferences` / :func:`save_preferences` instead of +inheriting camera and 3D-pane keys it has no use for; omitting the argument +keeps the historic behaviour, so existing callers are unaffected. +""" from __future__ import annotations @@ -44,6 +50,7 @@ _DEFAULTS: dict[str, Any] = { "image_view_mode": "arrows_pure", "arrow_rotation_deg": 90, "show_fps": False, + "raw_auto_zero": True, #: The GL profile the 3D view is started with. One per process: the surface #: format is chosen before ``QApplication`` exists and cannot be switched later. "gl_profile": "desktop33", @@ -74,22 +81,31 @@ def _load_json(path: Path) -> dict[str, Any]: return data if isinstance(data, dict) else {} -def default_preferences() -> dict[str, Any]: - merged = deepcopy(_DEFAULTS) +def default_preferences(schema: dict[str, Any] | None = None) -> dict[str, Any]: + """The schema's defaults, overlaid with any shipped ``preferences.default.json``. + + Args: + schema: the app's own defaults; ``None`` means this package's (studio's). + """ + merged = deepcopy(_DEFAULTS if schema is None else schema) merged.update(_load_json(preferences_default_path())) return merged -def load_preferences() -> dict[str, Any]: - prefs = default_preferences() +def load_preferences(schema: dict[str, Any] | None = None) -> dict[str, Any]: + """Stored preferences, with schema defaults filling anything absent.""" + prefs = default_preferences(schema) prefs.update(_load_json(preference_path())) return prefs -def save_preferences(prefs: dict[str, Any]) -> Path: +def save_preferences( + prefs: dict[str, Any], schema: dict[str, Any] | None = None +) -> Path: + """Persist preferences, writing the schema's defaults for absent keys.""" path = preference_path() path.parent.mkdir(parents=True, exist_ok=True) - payload = default_preferences() + payload = default_preferences(schema) payload.update(prefs) path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") return path diff --git a/shared/src/orisys_app_shared/scene3d/__init__.py b/shared/src/orisys_app_shared/scene3d/__init__.py index 0d2fc0a..d99ba06 100644 --- a/shared/src/orisys_app_shared/scene3d/__init__.py +++ b/shared/src/orisys_app_shared/scene3d/__init__.py @@ -15,6 +15,7 @@ from orisys_app_shared.scene3d.contracts import ( LENGTH_UNIT_M, SENSOR_FRAME, UNAVAILABLE_DESCRIPTOR, + ArrowInstances, CameraMatrices, CameraPreset, ColorScale, @@ -41,6 +42,7 @@ from orisys_app_shared.scene3d.contracts import ( SceneState, SurfaceBinding, SurfaceFieldFrame, + SurfaceMapping, ThreeDError, U32, VertexFields, @@ -49,6 +51,7 @@ from orisys_app_shared.scene3d.contracts import ( freeze, rest_descriptor, summarize_parts, + validate_arrow_instances, validate_assets, validate_binding, validate_display_settings, @@ -62,6 +65,7 @@ __all__ = [ "LENGTH_UNIT_M", "SENSOR_FRAME", "UNAVAILABLE_DESCRIPTOR", + "ArrowInstances", "CameraMatrices", "CameraPreset", "ColorScale", @@ -88,6 +92,7 @@ __all__ = [ "SceneState", "SurfaceBinding", "SurfaceFieldFrame", + "SurfaceMapping", "ThreeDError", "U32", "VertexFields", @@ -96,6 +101,7 @@ __all__ = [ "freeze", "rest_descriptor", "summarize_parts", + "validate_arrow_instances", "validate_assets", "validate_binding", "validate_display_settings", diff --git a/shared/src/orisys_app_shared/scene3d/composer.py b/shared/src/orisys_app_shared/scene3d/composer.py index e3c5924..f549c37 100644 --- a/shared/src/orisys_app_shared/scene3d/composer.py +++ b/shared/src/orisys_app_shared/scene3d/composer.py @@ -42,7 +42,13 @@ from orisys_app_shared.scene3d.contracts import ( validate_surface_frame, ) from orisys_app_shared.scene3d.legacy import vertex_normals +from orisys_app_shared.scene3d.overlays import ( + build_flow_overlay, + build_normal_overlay, + mesh_span_from_mapping, +) from orisys_app_shared.scene3d.sampling import SurfaceSampler +from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples #: Reported when a display gain is applied to a displacement that is only #: approximate. Informational: the picture is legitimate as an illustration, and @@ -75,6 +81,8 @@ class SceneComposer: #: draw, the picture is the asset's own geometry, and recomputing its normals #: every frame was pure cost (measured as the bulk of a 20k-vertex compose). self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {} + #: Last UV grid of "arrow was on" samples for flow hysteresis. + self._flow_previously_on: Optional[np.ndarray] = None def compose( self, @@ -134,6 +142,9 @@ class SceneComposer: # Measured before the frame is built, so the frame carries *its own* cost: the # shared ``last_stats`` object is rewritten by the next task. + flow_arrows, contact_arrow = self._build_overlays( + assets, frame, settings, diagnostics + ) total_ms = (time.perf_counter() - started) * 1000.0 self.last_stats.total_ms = total_ms @@ -162,10 +173,89 @@ class SceneComposer: # ``last_stats`` object is rewritten by the next task, so a reader on the # GUI thread would report another frame's cost. compose_ms=total_ms, + flow_arrows=flow_arrows, + contact_arrow=contact_arrow, ) validate_render_frame(result, vertices=int(rest_positions.shape[0])) return result + def _build_overlays( + self, + assets: SceneAssets, + frame: SurfaceFieldFrame, + settings: DisplaySettings, + diagnostics: list[Diagnostic], + ): + flow_arrows = None + contact_arrow = None + mapping = assets.surface_mapping + if mapping is None: + if settings.flow_arrows_enabled or settings.contact_arrow_enabled: + diagnostics.append( + diagnostic( + "field_unavailable", + "this scene package has no UV surface mapping; overlays are hidden", + ) + ) + return flow_arrows, contact_arrow + + policy = VisualPolicy( + flow_on_px=float(settings.flow_on_px), + flow_off_px=float(settings.flow_off_px), + flow_grid_spacing=int(settings.flow_grid_spacing), + max_arrows=int(settings.max_arrows), + ).normalised() + if ( + settings.flow_arrows_enabled + and frame.flow_uv is not None + and frame.flow_valid_mask is not None + ): + try: + flow = np.asarray(frame.flow_uv) + h, w = int(flow.shape[0]), int(flow.shape[1]) + previously_on = self._flow_previously_on + if previously_on is not None and previously_on.shape != (h, w): + previously_on = None + samples = select_flow_samples( + flow, + frame.flow_valid_mask, + policy, + previously_on=previously_on, + ) + on_mask = np.zeros((h, w), dtype=bool) + if samples.rows.size: + on_mask[samples.rows, samples.cols] = True + self._flow_previously_on = on_mask + flow_arrows, extra = build_flow_overlay( + samples, + mapping, + style=policy, + normal_offset_m=mesh_span_from_mapping(mapping) * 0.002, + ) + diagnostics.extend(extra) + except Exception as exc: # noqa: BLE001 + diagnostics.append( + diagnostic( + "field_unavailable", + f"flow overlay failed: {type(exc).__name__}: {exc}", + ) + ) + flow_arrows = None + self._flow_previously_on = None + else: + self._flow_previously_on = None + + if settings.contact_arrow_enabled and frame.contact_valid: + contact_arrow, extra = build_normal_overlay( + contact_uv_xy=frame.contact_uv_xy, + contact_found=frame.contact_found, + contact_valid=frame.contact_valid, + fn=float(frame.contact_fn), + mapping=mapping, + ) + diagnostics.extend(extra) + return flow_arrows, contact_arrow + def _rest(self, assets: SceneAssets) -> tuple[np.ndarray, np.ndarray]: """The asset's own positions and normals, computed once per asset. diff --git a/shared/src/orisys_app_shared/scene3d/contracts.py b/shared/src/orisys_app_shared/scene3d/contracts.py index 5bc5a04..f3e8654 100644 --- a/shared/src/orisys_app_shared/scene3d/contracts.py +++ b/shared/src/orisys_app_shared/scene3d/contracts.py @@ -188,6 +188,51 @@ class SurfaceFieldFrame: #: only claim). When it is older than ``identity.frame_id`` the array is a stale #: measurement: a consumer must not colour it as the current instant. computed_frame_id: Optional[int] = None + #: Optional flow field kept alongside a depth scalar so overlays can draw arrows + #: without forcing the colour channel to switch away from depth. + flow_uv: Optional[F32] = None # [H,W,2] + flow_valid_mask: Optional[Mask] = None # [H,W] + #: Contact centre in the same UV domain as ``scalar``; absent on older packets. + contact_uv_xy: Optional[tuple[float, float]] = None + contact_found: bool = False + contact_valid: bool = False + #: Normal-force reading used only for the contact-arrow length mapping. + contact_fn: float = 0.0 + + +ArrowKind = Literal["flow", "contact_normal"] + + +@dataclass(frozen=True) +class ArrowInstances: + """Per-frame arrow overlay geometry in the sensor frame (metres). + + Directions are unit vectors; lengths are in the same units as origins. + ``M`` is bounded by the visual policy so the GL layer never uploads unbounded + Python mesh objects. + """ + + origins: F32 # [M,3] + directions: F32 # [M,3], unit + lengths: F32 # [M] + colors: F32 # [M,4], RGBA 0–1 + kind: ArrowKind + #: Optional per-arrow thickness scale relative to a template (contact arrows). + thickness_scales: Optional[F32] = None # [M] + + +@dataclass(frozen=True) +class SurfaceMapping: + """UV-domain positions and normals in the sensor frame, for overlay geometry. + + Optional on older packages: without it the composer draws the scalar surface + only and reports a soft overlay degradation. + """ + + shape_hw: tuple[int, int] + positions_sensor_m: F32 # [H,W,3] + normals_sensor: F32 # [H,W,3], unit where valid + valid: Mask # [H,W] # -------------------------------------------------------------------- assets @@ -489,6 +534,9 @@ class SceneAssets: #: Where this package may be displayed (stage 5). ``None`` means "not locked", #: which is what every package written before the lock existed carries. platform_lock: Optional[PlatformLock] = None + #: UV→scene positions/normals for overlays. Optional so older packages degrade + #: to scalar-only rendering without failing to load. + surface_mapping: Optional[SurfaceMapping] = None def part(self, part_id: str) -> Optional[MeshPart]: for item in self.parts: @@ -530,6 +578,9 @@ class SceneCapabilities: displacement_available: bool = False displacement_quality: Optional[Quality] = None source_kind: str = "live" + #: Whether flow / contact overlays can be built for this source. + flow_arrows_available: bool = False + contact_arrow_available: bool = False @dataclass(frozen=True) @@ -548,6 +599,17 @@ class DisplaySettings: #: producer did not claim for this frame is refused before either is coloured (see #: ``orisys_studio.scene3d.adapters``). field: str = "depth" + #: Draw optical-flow arrows on the sensing surface (independent of ``field``). + flow_arrows_enabled: bool = True + #: Draw the yellow contact-normal arrow at the contact centre. + contact_arrow_enabled: bool = True + #: Flow magnitude (px) at which arrows appear / disappear (hysteresis: on > off). + flow_on_px: float = 0.4 + flow_off_px: float = 0.3 + #: UV grid spacing shared by 2D and 3D flow arrows. + flow_grid_spacing: int = 10 + #: Cap on arrows uploaded per overlay kind. + max_arrows: int = 400 @dataclass(frozen=True) @@ -579,6 +641,9 @@ class RenderFrame: #: What composing this frame cost, measured where it was composed. Carried per frame #: rather than read from the composer's shared counter, which the next task rewrites. compose_ms: float = 0.0 + #: Optional overlays; absent on older packages / frames without mapping. + flow_arrows: Optional[ArrowInstances] = None + contact_arrow: Optional[ArrowInstances] = None @dataclass(frozen=True) @@ -1031,6 +1096,37 @@ def validate_render_frame(frame: RenderFrame, *, vertices: int) -> None: raise error("field_domain_mismatch", "render scalar is float32 and valid is bool") if not frame.asset_hash.strip() or not frame.geometry_id.strip(): raise error("geometry_mismatch", "a render frame must name its asset and geometry") + for label, arrows in ( + ("flow_arrows", frame.flow_arrows), + ("contact_arrow", frame.contact_arrow), + ): + if arrows is None: + continue + validate_arrow_instances(arrows, name=label) + + +def validate_arrow_instances(arrows: ArrowInstances, *, name: str = "arrows") -> None: + """Shapes and finiteness for one overlay batch.""" + for field_name, array, width in ( + ("origins", arrows.origins, 3), + ("directions", arrows.directions, 3), + ("lengths", arrows.lengths, None), + ("colors", arrows.colors, 4), + ): + _readonly_c_contiguous(f"{name}.{field_name}", array) + count = int(arrows.origins.shape[0]) + _shape(f"{name}.origins", arrows.origins, (count, 3)) + _shape(f"{name}.directions", arrows.directions, (count, 3)) + _shape(f"{name}.lengths", arrows.lengths, (count,)) + _shape(f"{name}.colors", arrows.colors, (count, 4)) + _finite(f"{name}.origins", arrows.origins) + _finite(f"{name}.directions", arrows.directions) + _finite(f"{name}.lengths", arrows.lengths) + if arrows.thickness_scales is not None: + _readonly_c_contiguous(f"{name}.thickness_scales", arrows.thickness_scales) + _shape(f"{name}.thickness_scales", arrows.thickness_scales, (count,)) + if arrows.kind not in ("flow", "contact_normal"): + raise error("field_domain_mismatch", f"{name}: unknown arrow kind {arrows.kind!r}") #: The descriptor a reference frame carries: nothing is measured, and the UI has diff --git a/shared/src/orisys_app_shared/scene3d/legacy.py b/shared/src/orisys_app_shared/scene3d/legacy.py index 0df4ab4..1c44e1b 100644 --- a/shared/src/orisys_app_shared/scene3d/legacy.py +++ b/shared/src/orisys_app_shared/scene3d/legacy.py @@ -53,6 +53,7 @@ from orisys_app_shared.scene3d.digest import ( geometry_id, source_geometry_digest, ) +from orisys_app_shared.scene3d.overlays import build_surface_mapping #: Keys the importer needs, and what it does when they are missing. MESH_KEYS = ("vertices", "faces", "uv_data", "loop_to_vertex") @@ -573,6 +574,16 @@ class LegacyFingerImporter: # source vertex, and an operator asking "which triangle is this?" # deserves an answer rather than a guess. source_vertex_of_render=freeze(source_of_render, dtype=np.int32), + surface_mapping=( + build_surface_mapping( + domain.world_xyz, + domain.valid_mask, + meters_per_source_unit=units, + transform_sensor_from_source=transform, + ) + if domain.world_xyz is not None + else None + ), ) return assets diff --git a/shared/src/orisys_app_shared/scene3d/overlays.py b/shared/src/orisys_app_shared/scene3d/overlays.py new file mode 100644 index 0000000..67b8c89 --- /dev/null +++ b/shared/src/orisys_app_shared/scene3d/overlays.py @@ -0,0 +1,501 @@ +"""NumPy overlay geometry for 3D flow and contact-normal arrows. + +Ported from the old Open3D example semantics without importing Open3D or the +examples tree. Outputs :class:`ArrowInstances` for the GL backend. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Optional + +import numpy as np + +from orisys_app_shared.scene3d.contracts import ( + ArrowInstances, + Diagnostic, + SurfaceMapping, + diagnostic, + freeze, +) +from orisys_app_shared.scene3d.visual_policy import FlowSamples, VisualPolicy + +ForceStrengthMode = Literal["log", "power", "linear"] + + +@dataclass(frozen=True) +class ContactArrowStyle: + """Visual length/thickness mapping for the yellow contact-normal arrow.""" + + normal_force_scale: float = 1.0 + min_length_fraction: float = 0.02 + max_length_fraction: float = 0.30 + min_thickness_scale: float = 0.20 + max_thickness_scale: float = 1.0 + reference_normal_force: float = 10000.0 + force_strength_mode: ForceStrengthMode = "log" + force_strength_gamma: float = 0.5 + color_rgba: tuple[float, float, float, float] = (1.0, 0.92, 0.10, 1.0) + #: Lift the origin slightly along the outward normal to avoid z-fighting. + normal_offset_fraction: float = 0.005 + + +DEFAULT_CONTACT_ARROW_STYLE = ContactArrowStyle() + +FLOW_TAIL_RGBA = (38 / 255.0, 235 / 255.0, 89 / 255.0, 0.85) +FLOW_HEAD_RGBA = (13 / 255.0, 115 / 255.0, 31 / 255.0, 0.95) + + +def _empty_arrows(kind: str) -> ArrowInstances: + z3 = freeze(np.zeros((0, 3), dtype=np.float32), dtype=np.float32) + z1 = freeze(np.zeros((0,), dtype=np.float32), dtype=np.float32) + z4 = freeze(np.zeros((0, 4), dtype=np.float32), dtype=np.float32) + return ArrowInstances( + origins=z3, + directions=z3, + lengths=z1, + colors=z4, + kind=kind, # type: ignore[arg-type] + thickness_scales=z1, + ) + + +def force_visual_strength( + force: float, + *, + style: ContactArrowStyle = DEFAULT_CONTACT_ARROW_STYLE, +) -> float: + """Map normal force Fn to a unitless strength in [0, 1] for length/thickness.""" + reference = style.reference_normal_force + if force <= 0.0 or reference <= 0.0: + return 0.0 + ratio = force / reference + if style.force_strength_mode == "linear": + strength = ratio + elif style.force_strength_mode == "power": + strength = ratio ** style.force_strength_gamma + else: + strength = np.log1p(ratio) / np.log1p(1.0) + return float(np.clip(strength, 0.0, 1.0)) + + +def length_from_force( + force: float, + mesh_span: float, + *, + style: ContactArrowStyle = DEFAULT_CONTACT_ARROW_STYLE, +) -> float: + strength = force_visual_strength(force, style=style) + frac = style.min_length_fraction + ( + style.max_length_fraction - style.min_length_fraction + ) * strength + return float(mesh_span * frac * style.normal_force_scale) + + +def thickness_scale_from_force( + force: float, + *, + style: ContactArrowStyle = DEFAULT_CONTACT_ARROW_STYLE, +) -> float: + strength = force_visual_strength(force, style=style) + return float( + style.min_thickness_scale + + (style.max_thickness_scale - style.min_thickness_scale) * strength + ) + + +def mesh_span_from_mapping(mapping: SurfaceMapping) -> float: + positions = np.asarray(mapping.positions_sensor_m, dtype=np.float64) + valid = np.asarray(mapping.valid, dtype=bool) + if not np.any(valid): + return 1e-3 + pts = positions[valid] + extent = np.ptp(pts, axis=0) + return float(max(np.max(extent), 1e-6)) + + +def build_flow_overlay( + samples: FlowSamples, + mapping: SurfaceMapping, + *, + style: Optional[VisualPolicy] = None, + normal_offset_m: float = 0.0, +) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]: + """Map shared UV flow samples onto the sensing surface.""" + del style # lengths come from UV tip deltas projected into scene metres + if samples.rows.size == 0: + return _empty_arrows("flow"), () + + h, w = mapping.shape_hw + positions = np.asarray(mapping.positions_sensor_m, dtype=np.float64) + normals = np.asarray(mapping.normals_sensor, dtype=np.float64) + valid = np.asarray(mapping.valid, dtype=bool) + if positions.shape[:2] != (h, w) or normals.shape[:2] != (h, w): + return _empty_arrows("flow"), ( + diagnostic( + "geometry_mismatch", + "surface mapping shape disagrees with its declared domain", + ), + ) + + rows = np.asarray(samples.rows, dtype=np.int32) + cols = np.asarray(samples.cols, dtype=np.int32) + end_x = np.clip(np.round(cols + samples.dx).astype(np.int32), 0, w - 1) + end_y = np.clip(np.round(rows + samples.dy).astype(np.int32), 0, h - 1) + + inside = ( + (rows >= 0) + & (rows < h) + & (cols >= 0) + & (cols < w) + & valid[rows, cols] + & valid[end_y, end_x] + ) + if not np.any(inside): + return _empty_arrows("flow"), () + + rows = rows[inside] + cols = cols[inside] + end_x = end_x[inside] + end_y = end_y[inside] + origins = positions[rows, cols].copy() + ends = positions[end_y, end_x] + normals_o = normals[rows, cols] + if normal_offset_m: + origins = origins + normal_offset_m * normals_o + + vectors = ends - origins + lengths = np.linalg.norm(vectors, axis=1) + keep = lengths > 1e-9 + if not np.any(keep): + return _empty_arrows("flow"), () + origins = origins[keep] + vectors = vectors[keep] + lengths = lengths[keep] + directions = vectors / lengths[:, None] + + colors = np.tile(np.asarray(FLOW_TAIL_RGBA, dtype=np.float32), (len(origins), 1)) + # Stronger arrows tip toward the head colour. + t = np.clip(lengths / max(float(np.percentile(lengths, 95)), 1e-9), 0.0, 1.0) + head = np.asarray(FLOW_HEAD_RGBA, dtype=np.float32) + colors = colors * (1.0 - t)[:, None] + head * t[:, None] + + return ( + ArrowInstances( + origins=freeze(origins, dtype=np.float32), + directions=freeze(directions, dtype=np.float32), + lengths=freeze(lengths, dtype=np.float32), + colors=freeze(colors, dtype=np.float32), + kind="flow", + ), + (), + ) + + +def build_normal_overlay( + *, + contact_uv_xy: Optional[tuple[float, float]], + contact_found: bool, + contact_valid: bool, + fn: float, + mapping: SurfaceMapping, + style: ContactArrowStyle = DEFAULT_CONTACT_ARROW_STYLE, +) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]: + """Yellow arrow at the contact centre along the local surface normal. + + Length is a *display* mapping of FN (log/power), not a calibrated newton + vector. The arrow points opposite the outward normal (into the contact). + """ + if not contact_found or not contact_valid or contact_uv_xy is None: + return _empty_arrows("contact_normal"), () + force = max(float(fn), 0.0) + if force <= 0.0 or not np.isfinite(force): + return _empty_arrows("contact_normal"), () + + h, w = mapping.shape_hw + cx = int(round(float(contact_uv_xy[0]))) + cy = int(round(float(contact_uv_xy[1]))) + if not (0 <= cy < h and 0 <= cx < w): + return _empty_arrows("contact_normal"), ( + diagnostic( + "field_unavailable", + "contact centre lies outside the UV domain", + x=str(cx), + y=str(cy), + ), + ) + valid = np.asarray(mapping.valid, dtype=bool) + if not bool(valid[cy, cx]): + return _empty_arrows("contact_normal"), ( + diagnostic( + "field_unavailable", + "contact centre is outside the valid sensing mask", + x=str(cx), + y=str(cy), + ), + ) + + origin = np.asarray(mapping.positions_sensor_m[cy, cx], dtype=np.float64) + normal = np.asarray(mapping.normals_sensor[cy, cx], dtype=np.float64) + if not (np.all(np.isfinite(origin)) and np.all(np.isfinite(normal))): + return _empty_arrows("contact_normal"), () + n_norm = float(np.linalg.norm(normal)) + if n_norm < 1e-8: + return _empty_arrows("contact_normal"), () + direction = -normal / n_norm + span = mesh_span_from_mapping(mapping) + length = length_from_force(force, span, style=style) + length = float( + np.clip( + length, + span * style.min_length_fraction, + span * style.max_length_fraction, + ) + ) + if length <= 0.0: + return _empty_arrows("contact_normal"), () + offset = span * style.normal_offset_fraction + origin = origin + offset * (normal / n_norm) + thickness = thickness_scale_from_force(force, style=style) + color = np.asarray(style.color_rgba, dtype=np.float32).reshape(1, 4) + + return ( + ArrowInstances( + origins=freeze(origin.reshape(1, 3), dtype=np.float32), + directions=freeze(direction.reshape(1, 3), dtype=np.float32), + lengths=freeze(np.asarray([length], dtype=np.float32), dtype=np.float32), + colors=freeze(color, dtype=np.float32), + kind="contact_normal", + thickness_scales=freeze( + np.asarray([thickness], dtype=np.float32), dtype=np.float32 + ), + ), + (), + ) + + +def compute_surface_frames( + world_xyz: np.ndarray, + valid_mask: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Estimate tangent_x, tangent_y and unit normals on a UV grid. + + Mirrors the SDK ``compute_uv_surface_frames`` contract without importing it. + """ + xyz = np.asarray(world_xyz, dtype=np.float64) + mask = np.asarray(valid_mask, dtype=bool) + h, w = mask.shape + tangent_x = np.zeros((h, w, 3), dtype=np.float64) + tangent_y = np.zeros((h, w, 3), dtype=np.float64) + normals = np.zeros((h, w, 3), dtype=np.float64) + + # Forward differences where possible, else backward. + dx = np.zeros_like(xyz) + dy = np.zeros_like(xyz) + dx[:, :-1] = xyz[:, 1:] - xyz[:, :-1] + dx[:, -1] = xyz[:, -1] - xyz[:, -2] if w > 1 else 0.0 + dy[:-1, :] = xyz[1:, :] - xyz[:-1, :] + dy[-1, :] = xyz[-1, :] - xyz[-2, :] if h > 1 else 0.0 + + # Invalidate differences that leave the mask. + right_ok = np.zeros((h, w), dtype=bool) + left_ok = np.zeros((h, w), dtype=bool) + down_ok = np.zeros((h, w), dtype=bool) + up_ok = np.zeros((h, w), dtype=bool) + right_ok[:, :-1] = mask[:, :-1] & mask[:, 1:] + left_ok[:, 1:] = mask[:, 1:] & mask[:, :-1] + down_ok[:-1, :] = mask[:-1, :] & mask[1:, :] + up_ok[1:, :] = mask[1:, :] & mask[:-1, :] + + use_fwd_x = right_ok + use_back_x = (~right_ok) & left_ok + dx_back = np.zeros_like(xyz) + dx_back[:, 1:] = xyz[:, 1:] - xyz[:, :-1] + tx = np.where(use_fwd_x[..., None], dx, 0.0) + tx = np.where(use_back_x[..., None], dx_back, tx) + # Fallback axis for isolated pixels. + isolated_x = mask & ~(use_fwd_x | use_back_x) + tx[isolated_x] = np.array([1.0, 0.0, 0.0]) + + use_fwd_y = down_ok + use_back_y = (~down_ok) & up_ok + dy_back = np.zeros_like(xyz) + dy_back[1:, :] = xyz[1:, :] - xyz[:-1, :] + ty = np.where(use_fwd_y[..., None], dy, 0.0) + ty = np.where(use_back_y[..., None], dy_back, ty) + isolated_y = mask & ~(use_fwd_y | use_back_y) + ty[isolated_y] = np.array([0.0, 1.0, 0.0]) + + n = np.cross(tx, ty) + n_norm = np.linalg.norm(n, axis=-1, keepdims=True) + bad = n_norm[..., 0] < 1e-12 + n = np.divide(n, np.maximum(n_norm, 1e-12), out=np.zeros_like(n)) + n[bad] = np.array([0.0, 0.0, 1.0]) + + tx_norm = np.linalg.norm(tx, axis=-1, keepdims=True) + ty_norm = np.linalg.norm(ty, axis=-1, keepdims=True) + tangent_x = np.divide(tx, np.maximum(tx_norm, 1e-12), out=np.zeros_like(tx)) + tangent_y = np.divide(ty, np.maximum(ty_norm, 1e-12), out=np.zeros_like(ty)) + normals = n + tangent_x[~mask] = 0.0 + tangent_y[~mask] = 0.0 + normals[~mask] = 0.0 + return tangent_x, tangent_y, normals + + +def build_surface_mapping( + world_xyz: np.ndarray, + valid_mask: np.ndarray, + *, + meters_per_source_unit: float, + transform_sensor_from_source: np.ndarray, +) -> SurfaceMapping: + """Convert a legacy UV ``world_xyz`` grid into sensor-metre ``SurfaceMapping``.""" + units = float(meters_per_source_unit) + if not np.isfinite(units) or units <= 0.0: + raise ValueError("meters_per_source_unit must be finite and positive") + transform = np.asarray(transform_sensor_from_source, dtype=np.float64).reshape(4, 4) + xyz = np.asarray(world_xyz, dtype=np.float64) * units + mask = np.asarray(valid_mask, dtype=bool) + h, w = mask.shape + flat = xyz.reshape(-1, 3) + ones = np.ones((flat.shape[0], 1), dtype=np.float64) + homo = np.concatenate([flat, ones], axis=1) + sensor = (homo @ transform.T)[:, :3].reshape(h, w, 3) + _, _, normals = compute_surface_frames(sensor, mask) + # Rotate is already baked into sensor positions; recompute normals in sensor frame. + return SurfaceMapping( + shape_hw=(h, w), + positions_sensor_m=freeze(sensor, dtype=np.float32), + normals_sensor=freeze(normals, dtype=np.float32), + valid=freeze(mask, dtype=np.bool_), + ) + + +def _rotation_matrix_z_to(direction: np.ndarray) -> np.ndarray: + direction = np.asarray(direction, dtype=np.float64).reshape(3) + norm = np.linalg.norm(direction) + if norm < 1e-8: + return np.eye(3, dtype=np.float64) + direction = direction / norm + z_axis = np.array([0.0, 0.0, 1.0], dtype=np.float64) + cross = np.cross(z_axis, direction) + dot = float(np.dot(z_axis, direction)) + if dot > 0.9999: + return np.eye(3, dtype=np.float64) + if dot < -0.9999: + return np.diag([1.0, -1.0, -1.0]).astype(np.float64) + skew = np.array( + [ + [0.0, -cross[2], cross[1]], + [cross[2], 0.0, -cross[0]], + [-cross[1], cross[0], 0.0], + ], + dtype=np.float64, + ) + return np.eye(3, dtype=np.float64) + skew + skew @ skew * (1.0 / (1.0 + dot)) + + +def _cylinder_template(resolution: int = 8) -> tuple[np.ndarray, np.ndarray]: + """Unit-radius, unit-height cylinder along +Z, centred on the origin.""" + angles = np.linspace(0.0, 2.0 * np.pi, resolution, endpoint=False) + ring = np.column_stack([np.cos(angles), np.sin(angles), np.zeros(resolution)]) + bottom = ring.copy() + bottom[:, 2] = -0.5 + top = ring.copy() + top[:, 2] = 0.5 + vertices = np.vstack([bottom, top]) + triangles = [] + for i in range(resolution): + j = (i + 1) % resolution + triangles.append([i, j, resolution + i]) + triangles.append([j, resolution + j, resolution + i]) + return ( + np.asarray(vertices, dtype=np.float64), + np.asarray(triangles, dtype=np.int32), + ) + + +def _cone_template(resolution: int = 8) -> tuple[np.ndarray, np.ndarray]: + """Unit-radius cone with apex at z=1 and base at z=0.""" + angles = np.linspace(0.0, 2.0 * np.pi, resolution, endpoint=False) + base = np.column_stack([np.cos(angles), np.sin(angles), np.zeros(resolution)]) + apex = np.array([[0.0, 0.0, 1.0]], dtype=np.float64) + vertices = np.vstack([base, apex]) + apex_i = resolution + triangles = [[i, (i + 1) % resolution, apex_i] for i in range(resolution)] + return ( + np.asarray(vertices, dtype=np.float64), + np.asarray(triangles, dtype=np.int32), + ) + + +def arrow_instances_to_mesh( + arrows: ArrowInstances, + *, + default_radius_fraction: float = 0.02, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Merge arrow instances into one triangle mesh (positions, triangles, colors).""" + empty_v = np.zeros((0, 3), dtype=np.float32) + empty_t = np.zeros((0, 3), dtype=np.uint32) + empty_c = np.zeros((0, 4), dtype=np.float32) + count = int(arrows.origins.shape[0]) + if count == 0: + return empty_v, empty_t, empty_c + + cyl_v, cyl_t = _cylinder_template() + cone_v, cone_t = _cone_template() + origins = np.asarray(arrows.origins, dtype=np.float64) + directions = np.asarray(arrows.directions, dtype=np.float64) + lengths = np.asarray(arrows.lengths, dtype=np.float64) + colors = np.asarray(arrows.colors, dtype=np.float64) + thickness = ( + np.asarray(arrows.thickness_scales, dtype=np.float64) + if arrows.thickness_scales is not None + else np.ones(count, dtype=np.float64) + ) + + mean_length = float(np.mean(lengths)) if count else 1.0 + base_radius = max(mean_length * default_radius_fraction, 1e-5) + + parts_v: list[np.ndarray] = [] + parts_t: list[np.ndarray] = [] + parts_c: list[np.ndarray] = [] + offset = 0 + for i in range(count): + length = float(lengths[i]) + if length <= 1e-9: + continue + direction = directions[i] + origin = origins[i] + radius = base_radius * float(max(thickness[i], 0.05)) + rotation = _rotation_matrix_z_to(direction) + # Tail: 75% cylinder, head: 25% cone. + tail_len = length * 0.75 + head_len = length * 0.25 + # Cylinder from 0..tail_len along +Z then rotate. + scaled = cyl_v.copy() + scaled[:, 0] *= radius + scaled[:, 1] *= radius + scaled[:, 2] = (scaled[:, 2] + 0.5) * tail_len + tail = scaled @ rotation.T + origin + # Cone from tail_len..length. + scaled_c = cone_v.copy() + scaled_c[:, 0] *= radius * 1.8 + scaled_c[:, 1] *= radius * 1.8 + scaled_c[:, 2] = scaled_c[:, 2] * head_len + tail_len + head = scaled_c @ rotation.T + origin + + for verts, tris in ((tail, cyl_t), (head, cone_t)): + parts_v.append(verts) + parts_t.append(tris + offset) + parts_c.append(np.tile(colors[i], (len(verts), 1))) + offset += len(verts) + + if not parts_v: + return empty_v, empty_t, empty_c + return ( + np.vstack(parts_v).astype(np.float32), + np.vstack(parts_t).astype(np.uint32), + np.vstack(parts_c).astype(np.float32), + ) diff --git a/shared/src/orisys_app_shared/scene3d/package.py b/shared/src/orisys_app_shared/scene3d/package.py index b5a04b5..ae3c8ec 100644 --- a/shared/src/orisys_app_shared/scene3d/package.py +++ b/shared/src/orisys_app_shared/scene3d/package.py @@ -100,7 +100,12 @@ SENSING_ARRAYS = { "sensing/sample_indices.npy": ("i4", 2), "sensing/sample_weights.npy": ("f4", 2), } -OPTIONAL_ARRAYS = {"sensing/source_vertex.npy": ("i4", 1)} +OPTIONAL_ARRAYS = { + "sensing/source_vertex.npy": ("i4", 1), + "sensing/surface_positions.npy": ("f4", 3), + "sensing/surface_normals.npy": ("f4", 3), + "sensing/surface_valid.npy": ("i4", 2), +} HOUSING_ARRAYS = { "housing/positions.npy": ("f4", 2), "housing/normals.npy": ("f4", 2), @@ -228,6 +233,17 @@ def _arrays_of(assets: SceneAssets) -> dict[str, np.ndarray]: arrays["sensing/source_vertex.npy"] = np.ascontiguousarray( assets.source_vertex_of_render ) + if assets.surface_mapping is not None: + mapping = assets.surface_mapping + arrays["sensing/surface_positions.npy"] = np.ascontiguousarray( + mapping.positions_sensor_m + ) + arrays["sensing/surface_normals.npy"] = np.ascontiguousarray( + mapping.normals_sensor + ) + arrays["sensing/surface_valid.npy"] = np.ascontiguousarray( + np.asarray(mapping.valid, dtype=np.int32) + ) for part in assets.parts: if part.role == "housing": arrays["housing/positions.npy"] = np.ascontiguousarray(part.positions_sensor_m) @@ -684,6 +700,19 @@ def scene_from_contents( recomputed=recomputed_binding[:16], ) source = arrays.get("sensing/source_vertex.npy") + surface_pos = arrays.get("sensing/surface_positions.npy") + surface_nrm = arrays.get("sensing/surface_normals.npy") + surface_valid = arrays.get("sensing/surface_valid.npy") + surface_mapping = None + if surface_pos is not None and surface_nrm is not None and surface_valid is not None: + from orisys_app_shared.scene3d.contracts import SurfaceMapping + + surface_mapping = SurfaceMapping( + shape_hw=(shape[0], shape[1]), + positions_sensor_m=freeze(surface_pos, dtype=np.float32), + normals_sensor=freeze(surface_nrm, dtype=np.float32), + valid=freeze(np.asarray(surface_valid, dtype=bool), dtype=np.bool_), + ) compatible = dict(manifest.get("compatible") or {}) source_geometry = dict(manifest.get("source_geometry") or {}) assets = SceneAssets( @@ -719,6 +748,7 @@ def scene_from_contents( compatible_hardware_revisions=tuple( str(item) for item in (compatible.get("hardware_revisions") or ()) ), + surface_mapping=surface_mapping, ) validate_assets(assets, expected) return assets diff --git a/shared/src/orisys_app_shared/scene3d/synthetic.py b/shared/src/orisys_app_shared/scene3d/synthetic.py index fa5e632..ebde924 100644 --- a/shared/src/orisys_app_shared/scene3d/synthetic.py +++ b/shared/src/orisys_app_shared/scene3d/synthetic.py @@ -22,6 +22,7 @@ from orisys_app_shared.scene3d.contracts import ( MeshPart, SceneAssets, SurfaceBinding, + SurfaceMapping, freeze, ) from orisys_app_shared.scene3d.digest import field_domain_id, geometry_id @@ -130,6 +131,19 @@ def _binding() -> SurfaceBinding: ) +def _surface_mapping(plate: MeshPart) -> SurfaceMapping: + """Flat UV→scene mapping aligned with the synthetic plate grid.""" + positions = np.asarray(plate.positions_sensor_m, dtype=np.float32).reshape(GRID, GRID, 3) + normals = np.asarray(plate.normals_sensor, dtype=np.float32).reshape(GRID, GRID, 3) + valid = np.ones((GRID, GRID), dtype=bool) + return SurfaceMapping( + shape_hw=(GRID, GRID), + positions_sensor_m=freeze(positions, dtype=np.float32), + normals_sensor=freeze(normals, dtype=np.float32), + valid=freeze(valid, dtype=np.bool_), + ) + + def poc_scene() -> SceneAssets: """The synthetic scene: one sensing plate, one housing box, one preset.""" plate = _plate() @@ -166,6 +180,7 @@ def poc_scene() -> SceneAssets: ), field_domain_id=POC_DOMAIN_ID, shape_hw=(GRID, GRID), + surface_mapping=_surface_mapping(plate), ) diff --git a/shared/src/orisys_app_shared/scene3d/visual_policy.py b/shared/src/orisys_app_shared/scene3d/visual_policy.py new file mode 100644 index 0000000..722034e --- /dev/null +++ b/shared/src/orisys_app_shared/scene3d/visual_policy.py @@ -0,0 +1,231 @@ +"""Shared visual policy for 2D/3D flow sampling and domain masks. + +No Qt, no GL, no SDK: both the arrow canvas and the 3D overlays consume the same +sample points and thresholds so "arrows respond / depth does not" stays a +measurement difference rather than a display bug. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +import numpy as np + +from orisys_app_shared.scene3d.contracts import Diagnostic, Mask, diagnostic, error, freeze + + +@dataclass(frozen=True) +class VisualPolicy: + """Units are explicit: flow thresholds are pixels; depth thresholds are relative.""" + + flow_on_px: float = 0.4 + flow_off_px: float = 0.3 + flow_grid_spacing: int = 10 + flow_arrow_scale: float = 1.0 + flow_edge_margin: int = 8 + flow_length_margin: float = 0.8 + max_arrows: int = 400 + depth_on: float = 0.0 + depth_off: float = 0.0 + #: Inside / outside colours for the pure-color 2D canvas (BGR). + island_bgr: tuple[int, int, int] = (0x16, 0x13, 0x12) + exterior_bgr: tuple[int, int, int] = (0x0A, 0x09, 0x08) + + def normalised(self) -> "VisualPolicy": + on = max(0.0, float(self.flow_on_px)) + off = max(0.0, float(self.flow_off_px)) + if on < off: + on, off = off, on + spacing = max(1, int(self.flow_grid_spacing)) + return VisualPolicy( + flow_on_px=on, + flow_off_px=off, + flow_grid_spacing=spacing, + flow_arrow_scale=max(1e-9, float(self.flow_arrow_scale)), + flow_edge_margin=max(0, int(self.flow_edge_margin)), + flow_length_margin=max(0.05, float(self.flow_length_margin)), + max_arrows=max(1, int(self.max_arrows)), + depth_on=float(self.depth_on), + depth_off=float(self.depth_off), + island_bgr=tuple(int(v) for v in self.island_bgr), + exterior_bgr=tuple(int(v) for v in self.exterior_bgr), + ) + + +@dataclass(frozen=True) +class FlowSamples: + """Shared UV sample points for 2D and 3D flow arrows.""" + + rows: np.ndarray # [K] int + cols: np.ndarray # [K] int + dx: np.ndarray # [K] float — tip offset in UV pixels + dy: np.ndarray # [K] float + magnitude: np.ndarray # [K] float — |2*flow| + + +def build_domain_mask( + geometry_mask: Optional[np.ndarray], + texture_roi: Optional[np.ndarray] = None, + *, + shape_hw: Optional[tuple[int, int]] = None, +) -> tuple[Optional[Mask], tuple[Diagnostic, ...]]: + """Strict intersection of geometry and optional texture ROI. + + Shape mismatches raise rather than silently resize: a wrong mask invents + valid samples or erases the finger outline. + """ + if geometry_mask is None and texture_roi is None: + return None, () + diagnostics: list[Diagnostic] = [] + geo = None if geometry_mask is None else np.asarray(geometry_mask, dtype=bool) + roi = None if texture_roi is None else np.asarray(texture_roi, dtype=bool) + if shape_hw is not None: + expected = (int(shape_hw[0]), int(shape_hw[1])) + for name, array in (("geometry_mask", geo), ("texture_roi", roi)): + if array is None: + continue + if tuple(array.shape[:2]) != expected: + raise error( + "field_domain_mismatch", + f"{name} shape {tuple(array.shape[:2])} does not match " + f"domain {expected[0]}x{expected[1]}", + ) + if geo is not None and roi is not None: + if tuple(geo.shape[:2]) != tuple(roi.shape[:2]): + raise error( + "field_domain_mismatch", + "geometry_mask and texture_roi describe different grids", + geometry=f"{geo.shape[0]}x{geo.shape[1]}", + texture=f"{roi.shape[0]}x{roi.shape[1]}", + ) + mask = geo & roi + elif geo is not None: + mask = geo + else: + mask = roi + diagnostics.append( + diagnostic( + "geometry_unverified", + "display_mask uses texture_roi alone; no geometry_mask was given", + ) + ) + return freeze(mask, dtype=np.bool_), tuple(diagnostics) + + +def select_flow_samples( + flow: np.ndarray, + mask: Optional[np.ndarray], + policy: VisualPolicy, + *, + previously_on: Optional[np.ndarray] = None, +) -> FlowSamples: + """Sample optical flow on a UV grid with hysteresis and mask clipping. + + ``previously_on`` is a boolean grid of the same shape as ``flow`` (or flat + over the candidate points) used for on/off hysteresis; pass ``None`` to use + ``flow_on_px`` only (stateless). + """ + policy = policy.normalised() + flow = np.asarray(flow, dtype=np.float64) + if flow.ndim != 3 or flow.shape[-1] != 2: + raise error("field_domain_mismatch", "flow must be [H,W,2]") + h, w = int(flow.shape[0]), int(flow.shape[1]) + spacing = policy.flow_grid_spacing + # Never erase a small domain: an edge larger than half the short side leaves + # no legal sample points (the synthetic 3×3 PoC is the extreme case). + edge = min(int(policy.flow_edge_margin), max(0, (min(h, w) - 1) // 2)) + rr = np.arange(spacing // 2, h, spacing, dtype=np.int32) + cc = np.arange(spacing // 2, w, spacing, dtype=np.int32) + rr, cc = np.meshgrid(rr, cc, indexing="ij") + rr = rr.reshape(-1) + cc = cc.reshape(-1) + + disp = 2.0 * flow[rr, cc, :] + mag = np.linalg.norm(disp, axis=1) + arrow_scale = policy.flow_arrow_scale + dx = disp[:, 0] / arrow_scale + dy = disp[:, 1] / arrow_scale + length = np.sqrt(dx * dx + dy * dy) + margin = policy.flow_length_margin + scale = np.where( + length > spacing * margin, + spacing / np.maximum(length, 1e-9), + 1.0, + ) * margin + dx = dx * scale + dy = dy * scale + end_x = np.clip(np.round(cc + dx).astype(np.int32), 0, w - 1) + end_y = np.clip(np.round(rr + dy).astype(np.int32), 0, h - 1) + + threshold = policy.flow_on_px + if previously_on is not None: + prev = np.asarray(previously_on, dtype=bool) + if prev.shape == (h, w): + was_on = prev[rr, cc] + threshold = np.where(was_on, policy.flow_off_px, policy.flow_on_px) + + valid = ( + (cc >= edge) + & (cc < w - edge) + & (rr >= edge) + & (rr < h - edge) + & (mag >= threshold) + & np.isfinite(mag) + ) + if mask is not None: + m = np.asarray(mask, dtype=bool) + if tuple(m.shape[:2]) != (h, w): + raise error( + "field_domain_mismatch", + "flow mask shape does not match the flow field", + mask=f"{m.shape[0]}x{m.shape[1]}", + flow=f"{h}x{w}", + ) + valid &= m[rr, cc] + valid &= m[end_y, end_x] + + if not np.any(valid): + empty = np.empty(0, dtype=np.int32) + empty_f = np.empty(0, dtype=np.float64) + return FlowSamples(rows=empty, cols=empty, dx=empty_f, dy=empty_f, magnitude=empty_f) + + idx = np.flatnonzero(valid) + if idx.size > policy.max_arrows: + # Keep the strongest magnitudes when the grid is dense. + order = np.argsort(mag[idx])[::-1][: policy.max_arrows] + idx = idx[order] + + return FlowSamples( + rows=rr[idx].astype(np.int32, copy=False), + cols=cc[idx].astype(np.int32, copy=False), + dx=dx[idx].astype(np.float64, copy=False), + dy=dy[idx].astype(np.float64, copy=False), + magnitude=mag[idx].astype(np.float64, copy=False), + ) + + +def paint_domain_background( + shape_hw: tuple[int, int], + mask: Optional[np.ndarray], + *, + island_bgr: tuple[int, int, int], + exterior_bgr: tuple[int, int, int], +) -> np.ndarray: + """BGR canvas with distinct colours inside / outside the UV island.""" + h, w = int(shape_hw[0]), int(shape_hw[1]) + canvas = np.empty((h, w, 3), dtype=np.uint8) + canvas[:, :] = tuple(int(v) for v in exterior_bgr) + if mask is None: + canvas[:, :] = tuple(int(v) for v in island_bgr) + return canvas + m = np.asarray(mask, dtype=bool) + if tuple(m.shape[:2]) != (h, w): + raise error( + "field_domain_mismatch", + "background mask shape does not match the canvas", + mask=f"{m.shape[0]}x{m.shape[1]}", + canvas=f"{h}x{w}", + ) + canvas[m] = tuple(int(v) for v in island_bgr) + return canvas diff --git a/shared/src/orisys_app_shared/theme/colors_dark.json b/shared/src/orisys_app_shared/theme/colors_dark.json index bb891b9..0c5356e 100644 --- a/shared/src/orisys_app_shared/theme/colors_dark.json +++ b/shared/src/orisys_app_shared/theme/colors_dark.json @@ -46,6 +46,8 @@ "brand_bar_bg": "#12315D", "brand_bar_fg": "#FFFFFF", "brand_bar_fg_muted": "#B9C8E4", + "status_bar_bg": "#0B1017", + "status_bar_text": "#B9C2CF", "arrow_canvas": "#0B1830", "flow_ramp": "#0B1830,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00" } diff --git a/shared/src/orisys_app_shared/theme/colors_light.json b/shared/src/orisys_app_shared/theme/colors_light.json index 6e42185..2e18402 100644 --- a/shared/src/orisys_app_shared/theme/colors_light.json +++ b/shared/src/orisys_app_shared/theme/colors_light.json @@ -46,6 +46,8 @@ "brand_bar_bg": "#12315D", "brand_bar_fg": "#FFFFFF", "brand_bar_fg_muted": "#B9C8E4", + "status_bar_bg": "#EDF1F8", + "status_bar_text": "#141A24", "arrow_canvas": "#12315D", "flow_ramp": "#12315D,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00" } diff --git a/shared/src/orisys_app_shared/zmotion/__init__.py b/shared/src/orisys_app_shared/zmotion/__init__.py new file mode 100644 index 0000000..1c5ef49 --- /dev/null +++ b/shared/src/orisys_app_shared/zmotion/__init__.py @@ -0,0 +1,45 @@ +"""Typed, side-effect-free client for the ZMotion ZMC controller DLLs. + +Only the calls the AutoCal platform control needs are wrapped here, rather than +copying the vendor's 5381-line ``zmcdll/zauxdllPython.py``. The vendor wrapper +is transcribed method by method -- its Chinese docstrings are kept because they +document the parameter contract (the English reference for the same calls would +be the ``ZAUXDLL``/``ZAux_Direct_*`` API documentation). + +Differences from the vendor file that matter: + +* **No import side effects.** Importing this package does not probe, load, or + ``print()`` anything about the native library. The DLL is resolved and loaded + on first :func:`load_dll`, so a machine with no controller still imports the + whole app. +* **No path guessing at import time.** See :mod:`.loader`. +* **Errors are values, not tuples.** Every wrapper returns a + :class:`ZmotionResult` (or :class:`ZmotionValue` for reads), so a caller can + never silently treat a failed read's zero as data. + +No Qt binding is imported here, deliberately: this module is usable from a +headless test process and from either binding. +""" + +from __future__ import annotations + +from .client import ( + ZAuxError, + ZmotionClient, + ZmotionDllNotFound, + ZmotionResult, + ZmotionValue, +) +from .loader import ZAUXDLL_DLL, ZMOTION_DLL, load_dll, resolve_dll_dir + +__all__ = [ + "ZAUXDLL_DLL", + "ZMOTION_DLL", + "ZAuxError", + "ZmotionClient", + "ZmotionDllNotFound", + "ZmotionResult", + "ZmotionValue", + "load_dll", + "resolve_dll_dir", +] diff --git a/shared/src/orisys_app_shared/zmotion/client.py b/shared/src/orisys_app_shared/zmotion/client.py new file mode 100644 index 0000000..f62c42f --- /dev/null +++ b/shared/src/orisys_app_shared/zmotion/client.py @@ -0,0 +1,468 @@ +"""Typed wrappers around the ZMotion ``ZAux_*`` controller API. + +Every wrapper returns :class:`ZmotionResult` (commands) or :class:`ZmotionValue` +(reads) instead of the vendor's ``(code, ctypes_value)`` pairs, so a failed read +can never be mistaken for data. The ~25 calls here are exactly the ones the +AutoCal platform control uses; see ``ZAUXDLL2.h`` +(``shared/resources/zmotion/x64/``) for the underlying contract. + +Loading is lazy: constructing :class:`ZmotionClient` does not touch the native +library, so importing the app on a machine with no controller (or no DLL at all) +is always safe. Call :meth:`ZmotionClient.load` explicitly, or let +:meth:`ZmotionClient.connect` do it. +""" + +from __future__ import annotations + +import ctypes +from dataclasses import dataclass +from typing import Any # noqa: F401 (kept for ZmotionValue.value's annotation) + +from .loader import ZmotionDllNotFound, load_dll, resolve_dll_dir + +__all__ = [ + "ZAuxError", + "ZmotionClient", + "ZmotionDllNotFound", + "ZmotionResult", + "ZmotionValue", +] + + +class ZAuxError(RuntimeError): + """A ``ZAux_*`` call returned a non-zero code. + + Carries the numeric code so callers can map it to a localised message + without re-parsing a string. + """ + + def __init__(self, code: int, detail: str = "") -> None: + self.code = int(code) + self.detail = detail + message = f"ZMotion error {self.code}" + if detail: + message = f"{message}: {detail}" + super().__init__(message) + + +@dataclass(frozen=True) +class ZmotionResult: + """Outcome of a command that returns only a status code.""" + + code: int + + @property + def ok(self) -> bool: + return self.code == 0 + + def raise_for_status(self, detail: str = "") -> "ZmotionResult": + if not self.ok: + raise ZAuxError(self.code, detail) + return self + + +@dataclass(frozen=True) +class ZmotionValue: + """Outcome of a read: a status code plus the value it produced.""" + + code: int + value: Any + + @property + def ok(self) -> bool: + return self.code == 0 + + @property + def value_or_none(self) -> Any | None: + """The value when the read succeeded, otherwise ``None``. + + Callers that want a default must say so themselves; silently using a + failed read's zero is what makes a wrong number look like a + measurement. + """ + return self.value if self.ok else None + + def raise_for_status(self, detail: str = "") -> "ZmotionValue": + if not self.ok: + raise ZAuxError(self.code, detail) + return self + + +class ZmotionClient: + """The controller connection, one instance per process. + + Not thread-safe: the underlying DLL handle is shared state. AutoCal owns one + client inside a single poller thread (see + ``orisys_autocal.motion.poller``) and never calls it from two threads. + """ + + def __init__(self) -> None: + self._dll: ctypes.CDLL | None = None + self._handle = ctypes.c_void_p() + self._dll_dir: str | None = None + + # ------------------------------------------------------------------ state + + @property + def loaded(self) -> bool: + return self._dll is not None + + @property + def connected(self) -> bool: + return self._handle.value is not None + + @property + def dll_dir(self) -> str | None: + """Directory the runtime was loaded from, for logs and About boxes.""" + return self._dll_dir + + def load(self) -> "ZmotionClient": + """Resolve and load the native runtime. Idempotent.""" + if self._dll is None: + resolved = resolve_dll_dir() + self._dll = load_dll(resolved) + self._dll_dir = str(resolved) + return self + + def _require_handle(self) -> ctypes.c_void_p: + if self._handle.value is None: + raise ZAuxError(-2, "no controller connection is open") + return self._handle + + # ------------------------------------------------------------ connection + + def open_eth(self, ip: str) -> ZmotionResult: + """Connect to a controller by IPv4 address (string form).""" + self.load() + self._handle = ctypes.c_void_p() + ret = self._dll.ZAux_OpenEth( # type: ignore[union-attr] + ip.encode("utf-8"), ctypes.pointer(self._handle) + ) + if ret != 0: + self._handle = ctypes.c_void_p() + return ZmotionResult(int(ret)) + + def open_com(self, com_id: int) -> ZmotionResult: + """Connect over a serial port by index.""" + self.load() + self._handle = ctypes.c_void_p() + ret = self._dll.ZAux_OpenCom( # type: ignore[union-attr] + ctypes.c_int(com_id), ctypes.pointer(self._handle) + ) + if ret != 0: + self._handle = ctypes.c_void_p() + return ZmotionResult(int(ret)) + + def open_pci(self, card_num: int) -> ZmotionResult: + """Connect to a PCI/PCIe card by card index.""" + self.load() + self._handle = ctypes.c_void_p() + ret = self._dll.ZAux_OpenPci( # type: ignore[union-attr] + ctypes.c_int(card_num), ctypes.pointer(self._handle) + ) + if ret != 0: + self._handle = ctypes.c_void_p() + return ZmotionResult(int(ret)) + + def fast_open(self, conn_type: int, connect_string: str, timeout_ms: int) -> ZmotionResult: + """Open a local/simulated controller (``ZAux_FastOpen``).""" + self.load() + self._handle = ctypes.c_void_p() + ret = self._dll.ZAux_FastOpen( # type: ignore[union-attr] + ctypes.c_int(conn_type), + connect_string.encode("utf-8"), + ctypes.c_uint32(timeout_ms), + ctypes.pointer(self._handle), + ) + if ret != 0: + self._handle = ctypes.c_void_p() + return ZmotionResult(int(ret)) + + def close(self) -> ZmotionResult: + """Close the connection. Safe to call when nothing is open.""" + if self._dll is None or self._handle.value is None: + self._handle = ctypes.c_void_p() + return ZmotionResult(0) + ret = self._dll.ZAux_Close(self._handle) # type: ignore[union-attr] + self._handle = ctypes.c_void_p() + return ZmotionResult(int(ret)) + + def search_eth(self, buffer_length: int = 10240, timeout_ms: int = 200) -> ZmotionValue: + """Scan the local network segment for controllers. + + Returns the whitespace-separated address list as text. Does not need an + open connection. + """ + self.load() + buffer = (ctypes.c_char * int(buffer_length))() + ret = self._dll.ZAux_SearchEthlist( # type: ignore[union-attr] + buffer, ctypes.c_uint32(buffer_length), ctypes.c_uint32(timeout_ms) + ) + text = buffer.value.decode("utf-8", errors="ignore") if buffer.value else "" + return ZmotionValue(int(ret), text.strip()) + + # ----------------------------------------------------------- BAS / command + + def bas_download(self, filename: str, run_mode: int = 1) -> ZmotionResult: + """Download a ``.bas`` program to the controller ROM. + + ``run_mode=1`` downloads to ROM, which makes the controller run the + program's task after the configured delay (see ``ECAT.bas``). + """ + ret = self._dll.ZAux_BasDown( # type: ignore[union-attr] + self._require_handle(), filename.encode("utf-8"), ctypes.c_uint32(run_mode) + ) + return ZmotionResult(int(ret)) + + def execute(self, command: str) -> ZmotionValue: + """Run an online command string (e.g. ``RUN "ECAT.bas",4``).""" + ret = self._dll.ZAux_Execute( # type: ignore[union-attr] + self._require_handle(), + command.encode("utf-8"), + None, + ctypes.c_uint32(0), + ) + return ZmotionValue(int(ret), command) + + def get_user_var(self, name: str) -> ZmotionValue: + """Read a global variable from the running ``.bas`` program. + + The vendor wrapper returns a ``c_float`` here while other reads return a + plain float; both are normalised to a Python float. + """ + value = ctypes.c_float() + ret = self._dll.ZAux_Direct_GetUserVar( # type: ignore[union-attr] + self._require_handle(), name.encode("utf-8"), ctypes.byref(value) + ) + return ZmotionValue(int(ret), float(value.value)) + + # --------------------------------------------------------------- bus state + + def bus_node_count(self, slot: int = 0) -> ZmotionValue: + """Number of slave nodes the controller found on the bus.""" + value = ctypes.c_uint32() + ret = self._dll.ZAux_BusCmd_GetNodeNum( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(slot), ctypes.byref(value) + ) + return ZmotionValue(int(ret), int(value.value)) + + def bus_home_status(self, axis: int) -> ZmotionValue: + """Driver homing status: 1 once homing has completed.""" + value = ctypes.c_int() + ret = self._dll.ZAux_BusCmd_GetHomeStatus( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.byref(value) + ) + return ZmotionValue(int(ret), int(value.value)) + + def bus_datum(self, axis: int, home_mode: int) -> ZmotionResult: + """Start driver homing with a CiA-402 homing method.""" + ret = self._dll.ZAux_BusCmd_Datum( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.c_int(home_mode) + ) + return ZmotionResult(int(ret)) + + def bus_set_datum_offset(self, axis: int, value: float) -> ZmotionResult: + """Set the homing offset (machine units).""" + ret = self._dll.ZAux_BusCmd_SetDatumOffpos( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value) + ) + return ZmotionResult(int(ret)) + + def bus_drive_clear(self, axis: int, mode: int = 0) -> ZmotionResult: + """Clear a drive fault/alert.""" + ret = self._dll.ZAux_BusCmd_DriveClear( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.c_int(mode) + ) + return ZmotionResult(int(ret)) + + def sdo_read( + self, slot: int, node: int, index: int, subindex: int, data_type: int + ) -> ZmotionValue: + """Read an EtherCAT object-dictionary entry. + + ``data_type``: 1=int8, 2=int16, 3=int32, 4=uint8, 5=uint16, 6=uint32. + """ + value = ctypes.c_int() + ret = self._dll.ZAux_BusCmd_SDORead( # type: ignore[union-attr] + self._require_handle(), + ctypes.c_int(slot), + ctypes.c_int(node), + ctypes.c_int(index), + ctypes.c_int(subindex), + ctypes.c_int(data_type), + ctypes.byref(value), + ) + return ZmotionValue(int(ret), int(value.value)) + + def sdo_write( + self, + slot: int, + node: int, + index: int, + subindex: int, + data_type: int, + value: int, + ) -> ZmotionResult: + """Write an EtherCAT object-dictionary entry. See :meth:`sdo_read`.""" + ret = self._dll.ZAux_BusCmd_SDOWrite( # type: ignore[union-attr] + self._require_handle(), + ctypes.c_int(slot), + ctypes.c_int(node), + ctypes.c_int(index), + ctypes.c_int(subindex), + ctypes.c_int(data_type), + ctypes.c_int(value), + ) + return ZmotionResult(int(ret)) + + def rtex_read(self, axis: int, parameter: int) -> ZmotionValue: + """Read an RTEX drive parameter.""" + value = ctypes.c_float() + ret = self._dll.ZAux_BusCmd_RtexRead( # type: ignore[union-attr] + self._require_handle(), + ctypes.c_int(axis), + ctypes.c_int(parameter), + ctypes.byref(value), + ) + return ZmotionValue(int(ret), float(value.value)) + + def rtex_write(self, axis: int, parameter: int, value: float) -> ZmotionResult: + """Write an RTEX drive parameter.""" + ret = self._dll.ZAux_BusCmd_RtexWrite( # type: ignore[union-attr] + self._require_handle(), + ctypes.c_int(axis), + ctypes.c_int(parameter), + ctypes.c_float(value), + ) + return ZmotionResult(int(ret)) + + # ------------------------------------------------------------------- axis + + def set_axis_enable(self, axis: int, value: int) -> ZmotionResult: + """Servo enable (1) / disable (0) for one axis.""" + ret = self._dll.ZAux_Direct_SetAxisEnable( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.c_int(value) + ) + return ZmotionResult(int(ret)) + + def get_axis_enable(self, axis: int) -> ZmotionValue: + value = ctypes.c_int() + ret = self._dll.ZAux_Direct_GetAxisEnable( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.byref(value) + ) + return ZmotionValue(int(ret), int(value.value)) + + def set_units(self, axis: int, value: float) -> ZmotionResult: + """Pulse equivalent (user units per pulse).""" + ret = self._dll.ZAux_Direct_SetUnits( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value) + ) + return ZmotionResult(int(ret)) + + def get_units(self, axis: int) -> ZmotionValue: + value = ctypes.c_float() + ret = self._dll.ZAux_Direct_GetUnits( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.byref(value) + ) + return ZmotionValue(int(ret), float(value.value)) + + def set_speed(self, axis: int, value: float) -> ZmotionResult: + ret = self._dll.ZAux_Direct_SetSpeed( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value) + ) + return ZmotionResult(int(ret)) + + def get_speed(self, axis: int) -> ZmotionValue: + value = ctypes.c_float() + ret = self._dll.ZAux_Direct_GetSpeed( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.byref(value) + ) + return ZmotionValue(int(ret), float(value.value)) + + def set_accel(self, axis: int, value: float) -> ZmotionResult: + ret = self._dll.ZAux_Direct_SetAccel( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value) + ) + return ZmotionResult(int(ret)) + + def get_accel(self, axis: int) -> ZmotionValue: + value = ctypes.c_float() + ret = self._dll.ZAux_Direct_GetAccel( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.byref(value) + ) + return ZmotionValue(int(ret), float(value.value)) + + def set_creep(self, axis: int, value: float) -> ZmotionResult: + """Creep (slow homing) speed.""" + ret = self._dll.ZAux_Direct_SetCreep( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value) + ) + return ZmotionResult(int(ret)) + + def get_atype(self, axis: int) -> ZmotionValue: + """Axis type (65=position, 66=speed, 67=torque).""" + value = ctypes.c_int() + ret = self._dll.ZAux_Direct_GetAtype( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.byref(value) + ) + return ZmotionValue(int(ret), int(value.value)) + + def get_dpos(self, axis: int) -> ZmotionValue: + """Commanded position, in user units.""" + value = ctypes.c_float() + ret = self._dll.ZAux_Direct_GetDpos( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.byref(value) + ) + return ZmotionValue(int(ret), float(value.value)) + + def get_mpos(self, axis: int) -> ZmotionValue: + """Measured (feedback) position, in user units.""" + value = ctypes.c_float() + ret = self._dll.ZAux_Direct_GetMpos( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.byref(value) + ) + return ZmotionValue(int(ret), float(value.value)) + + def get_axis_status(self, axis: int) -> ZmotionValue: + """Axis status word. Bit 6 is the homing-in-progress bit.""" + value = ctypes.c_int() + ret = self._dll.ZAux_Direct_GetAxisStatus( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.byref(value) + ) + return ZmotionValue(int(ret), int(value.value)) + + def get_if_idle(self, axis: int) -> ZmotionValue: + """0 while the axis is moving, -1 when it has stopped.""" + value = ctypes.c_int() + ret = self._dll.ZAux_Direct_GetIfIdle( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.byref(value) + ) + return ZmotionValue(int(ret), int(value.value)) + + def single_vmove(self, axis: int, direction: int) -> ZmotionResult: + """Continuous jog: ``direction`` 1 for forward, -1 for reverse.""" + ret = self._dll.ZAux_Direct_Single_Vmove( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.c_int(direction) + ) + return ZmotionResult(int(ret)) + + def single_move(self, axis: int, distance: float) -> ZmotionResult: + """Move by a distance relative to the current position.""" + ret = self._dll.ZAux_Direct_Single_Move( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.c_float(distance) + ) + return ZmotionResult(int(ret)) + + def single_move_abs(self, axis: int, position: float) -> ZmotionResult: + """Move to an absolute position.""" + ret = self._dll.ZAux_Direct_Single_MoveAbs( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.c_float(position) + ) + return ZmotionResult(int(ret)) + + def single_cancel(self, axis: int, mode: int = 2) -> ZmotionResult: + """Stop the axis. ``mode=2`` is a decelerating stop.""" + ret = self._dll.ZAux_Direct_Single_Cancel( # type: ignore[union-attr] + self._require_handle(), ctypes.c_int(axis), ctypes.c_int(mode) + ) + return ZmotionResult(int(ret)) diff --git a/shared/src/orisys_app_shared/zmotion/loader.py b/shared/src/orisys_app_shared/zmotion/loader.py new file mode 100644 index 0000000..75cf7c9 --- /dev/null +++ b/shared/src/orisys_app_shared/zmotion/loader.py @@ -0,0 +1,143 @@ +"""Resolve and load the vendored ZMotion runtime DLLs. + +Resolution order: + +1. ``ORISYS_ZMOTION_DLL_DIR`` — an explicit override, e.g. a newer + controller-support CD. +2. ``/_internal/zmotion/x64`` and ``/zmotion/x64`` — a frozen + (PyInstaller) bundle, matching how ``build/studio`` places branding. +3. ``shared/resources/zmotion/x64`` in the source tree — the vendored copy + (see ``shared/resources/zmotion/x64/README.md`` for provenance). + +Nothing here runs at import time; :func:`load_dll` is called by the app once a +connection is actually requested. +""" + +from __future__ import annotations + +import ctypes +import os +import sys +from pathlib import Path + +ZAUXDLL_DLL = "zauxdll.dll" +ZMOTION_DLL = "zmotion.dll" + +#: Environment variable that overrides the vendored DLL directory. +DLL_DIR_ENV = "ORISYS_ZMOTION_DLL_DIR" + + +class ZmotionDllNotFound(OSError): + """The ZMotion runtime could not be located. + + Carries every path that was searched, so the message can tell an operator + what to install or which environment variable to set instead of just + naming the missing file. + """ + + def __init__(self, name: str, searched: list[Path]) -> None: + self.name = name + self.searched = searched + listing = "\n".join(f" - {path}" for path in searched) + super().__init__( + f"{name} not found. Searched:\n{listing}\n" + f"Install the ZMotion runtime, or set {DLL_DIR_ENV} to the folder " + f"that contains it." + ) + + +def _source_tree_dir() -> Path: + # .../orisys-app/shared/src/orisys_app_shared/zmotion/loader.py -> shared/ + return Path(__file__).resolve().parents[3] / "resources" / "zmotion" / "x64" + + +def _frozen_dirs() -> list[Path]: + if not getattr(sys, "frozen", False): + return [] + exe_dir = Path(sys.executable).resolve().parent + return [exe_dir / "_internal" / "zmotion" / "x64", exe_dir / "zmotion" / "x64"] + + +def candidate_dll_dirs() -> list[Path]: + """Every directory that will be searched for the runtime DLLs, in order. + + An explicit ``ORISYS_ZMOTION_DLL_DIR`` is authoritative: it is the only + candidate, so a typo fails loudly instead of quietly loading a different + build of the runtime than the operator asked for. On a motion controller, + silently using the wrong DLL version is exactly the kind of failure that + looks like a working machine until it moves. + """ + override = os.environ.get(DLL_DIR_ENV, "").strip() + if override: + return [Path(override).expanduser()] + + candidates: list[Path] = [] + candidates.extend(_frozen_dirs()) + candidates.append(_source_tree_dir()) + + seen: set[Path] = set() + unique: list[Path] = [] + for path in candidates: + resolved = path.resolve() if path.exists() else path + if resolved not in seen: + seen.add(resolved) + unique.append(resolved) + return unique + + +def resolve_dll_dir() -> Path: + """Return the first candidate directory that holds ``zauxdll.dll``. + + Raises: + ZmotionDllNotFound: no candidate directory contains the runtime. + """ + searched = candidate_dll_dirs() + for path in searched: + if (path / ZAUXDLL_DLL).is_file(): + return path + raise ZmotionDllNotFound(ZAUXDLL_DLL, searched) + + +def _prepare_search_path(dll_dir: Path) -> None: + """Make ``dll_dir`` available to the loader for dependent DLLs. + + ``zauxdll.dll`` links against ``zmotion.dll`` beside it, so the directory + must be on the search path *before* ``zauxdll.dll`` is loaded. Both the + process ``PATH`` and the loader's own DLL directory are set: the former is + what the vendor wrapper relied on, the latter is the documented Win32 + mechanism. + """ + dll_dir_str = str(dll_dir) + path_entries = os.environ.get("PATH", "").split(os.pathsep) + if dll_dir_str not in path_entries: + os.environ["PATH"] = dll_dir_str + os.pathsep + os.environ.get("PATH", "") + + if sys.platform == "win32": + try: + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.SetDllDirectoryW(ctypes.c_wchar_p(dll_dir_str)) + except (OSError, AttributeError): # pragma: no cover - defensive + # Not fatal: the PATH entry above is usually enough, and the real + # failure (if any) surfaces as a clearer error from load_dll. + pass + + +def load_dll(dll_dir: Path | None = None) -> ctypes.CDLL: + """Load ``zauxdll.dll`` with its directory on the search path. + + Args: + dll_dir: directory to load from; resolved automatically when omitted. + + Returns: + The loaded CDLL. + + Raises: + ZmotionDllNotFound: the runtime is not present. + OSError: the DLL is present but the platform loader rejected it. + """ + resolved = dll_dir or resolve_dll_dir() + dll_path = resolved / ZAUXDLL_DLL + if not dll_path.is_file(): + raise ZmotionDllNotFound(ZAUXDLL_DLL, [resolved]) + _prepare_search_path(resolved) + return ctypes.CDLL(str(dll_path)) diff --git a/shared/tests/test_force_presentation.py b/shared/tests/test_force_presentation.py new file mode 100644 index 0000000..3d4a7d2 --- /dev/null +++ b/shared/tests/test_force_presentation.py @@ -0,0 +1,87 @@ +"""Display-only force baseline / deadband (value cards).""" + +from __future__ import annotations + +import numpy as np + +from orisys_app_shared.force.presentation import ForcePresentation, ZeroEstimate + + +def test_fit_zero_rejects_insufficient_samples() -> None: + presentation = ForcePresentation(enabled=True, min_samples=30) + estimate = presentation.fit_zero([(1.0, 2.0, 3.0), (1.1, 2.1, 3.1)]) + assert estimate.quality == "insufficient" + + +def test_fit_zero_and_accept_deadband() -> None: + rng = np.random.default_rng(0) + bias = np.array([1262.0, -11.0, -353.0]) + noise = rng.normal(0.0, 20.0, size=(80, 3)) + samples = (bias + noise).tolist() + presentation = ForcePresentation( + enabled=True, + min_samples=30, + baseline_seconds=0.0, + min_deadband=50.0, + deadband_percentile=99.0, + ) + estimate = presentation.fit_zero(samples) + assert estimate.quality == "ok" + presentation.apply_estimate(estimate) + idle = presentation.accept(tuple(bias), unit="raw") + assert idle.zero_zone + assert idle.values == (0.0, 0.0, 0.0) + # Curves / CSV keep the measurement: accept never mutates the input tuple. + measured = (1262.0, -11.0, -353.0) + presentation.accept(measured, unit="raw") + assert measured == (1262.0, -11.0, -353.0) + + +def test_observe_skips_contact_contamination() -> None: + presentation = ForcePresentation( + enabled=True, + min_samples=5, + baseline_seconds=0.1, + require_no_contact=True, + ) + for i in range(10): + out = presentation.observe( + (1000.0, 0.0, 0.0), + t_rel_s=i * 0.05, + contact_found=True, + flow_p95=0.1, + ) + assert out is None + assert not presentation.ready + + +def test_observe_freezes_after_quiet_window() -> None: + presentation = ForcePresentation( + enabled=True, + min_samples=10, + baseline_seconds=0.5, + min_deadband=10.0, + ) + estimate: ZeroEstimate | None = None + for i in range(40): + estimate = presentation.observe( + (100.0 + (i % 3), -5.0, 2.0), + t_rel_s=i * 0.02, + flow_p95=0.1, + contact_found=False, + ) + if estimate is not None: + break + assert estimate is not None + assert estimate.quality == "ok" + assert presentation.ready + # Further observes do not chase a new zero once frozen. + later = presentation.observe((5000.0, 0.0, 0.0), t_rel_s=10.0, flow_p95=0.0) + assert later is None + + +def test_format_force_stable_digits() -> None: + presentation = ForcePresentation(enabled=True) + assert presentation.format_force(0.0, unit="raw") == "0" + assert presentation.format_force(1262.4, unit="raw") == "1262" + assert presentation.format_force(1.2345, unit="N") == "1.234" diff --git a/shared/tests/test_image_backend.py b/shared/tests/test_image_backend.py index 186bb6b..3c02f7f 100644 --- a/shared/tests/test_image_backend.py +++ b/shared/tests/test_image_backend.py @@ -127,6 +127,7 @@ def test_the_backend_renders_what_the_primitives_rendered_before(): background="captured" if mode == "arrows_stitched" else "pure_color", draw_style="finger", valid_mask=mask, + colormap=flow_colormap("dark"), ) got = backend.render_image(frame, plan, mode=mode, draw_style="finger") assert np.array_equal(got, expected), mode diff --git a/shared/tests/test_overlays.py b/shared/tests/test_overlays.py new file mode 100644 index 0000000..4161529 --- /dev/null +++ b/shared/tests/test_overlays.py @@ -0,0 +1,73 @@ +"""Pure-NumPy overlay geometry for Finger 3D arrows.""" + +from __future__ import annotations + +import numpy as np + +from orisys_app_shared.scene3d.overlays import ( + arrow_instances_to_mesh, + build_flow_overlay, + build_normal_overlay, + force_visual_strength, +) +from orisys_app_shared.scene3d.synthetic import poc_scene +from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples + + +def test_force_visual_strength_is_capped() -> None: + assert force_visual_strength(0.0) == 0.0 + strong = force_visual_strength(1e9) + mild = force_visual_strength(100.0) + assert strong >= mild > 0.0 + assert strong <= 1.0 + 1e-9 + + +def test_flow_and_normal_overlays_on_poc_mapping() -> None: + assets = poc_scene() + mapping = assets.surface_mapping + assert mapping is not None + h, w = mapping.shape_hw + flow = np.zeros((h, w, 2), dtype=np.float64) + flow[1, 1] = (0.8, 0.0) + mask = np.asarray(mapping.valid, dtype=bool) + policy = VisualPolicy( + flow_on_px=0.4, + flow_grid_spacing=1, + flow_edge_margin=0, + max_arrows=50, + ) + samples = select_flow_samples(flow, mask, policy) + arrows, diags = build_flow_overlay(samples, mapping, style=policy) + assert not diags + assert arrows is not None and arrows.origins.shape[0] >= 1 + contact, cdiags = build_normal_overlay( + contact_uv_xy=(1.0, 1.0), + contact_found=True, + contact_valid=True, + fn=500.0, + mapping=mapping, + ) + assert not cdiags + assert contact is not None and contact.origins.shape[0] == 1 + # Yellow-ish contact colour. + assert float(contact.colors[0, 0]) > 0.7 + assert float(contact.colors[0, 1]) > 0.7 + + positions, triangles, colors = arrow_instances_to_mesh(arrows) + assert positions.ndim == 2 and positions.shape[1] == 3 + assert triangles.ndim == 2 and triangles.shape[1] == 3 + assert colors.shape[0] == positions.shape[0] + + +def test_missing_contact_yields_no_arrow() -> None: + assets = poc_scene() + mapping = assets.surface_mapping + assert mapping is not None + arrows, _ = build_normal_overlay( + contact_uv_xy=(1.0, 1.0), + contact_found=False, + contact_valid=True, + fn=100.0, + mapping=mapping, + ) + assert arrows.origins.shape[0] == 0 diff --git a/shared/tests/test_visual_policy.py b/shared/tests/test_visual_policy.py new file mode 100644 index 0000000..e160424 --- /dev/null +++ b/shared/tests/test_visual_policy.py @@ -0,0 +1,73 @@ +"""Shared 2D/3D flow sampling and domain-mask rules.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from orisys_app_shared.scene3d.contracts import ThreeDError +from orisys_app_shared.scene3d.visual_policy import ( + VisualPolicy, + build_domain_mask, + paint_domain_background, + select_flow_samples, +) + + +def test_build_domain_mask_rejects_shape_mismatch() -> None: + geo = np.ones((4, 5), dtype=bool) + with pytest.raises(ThreeDError): + build_domain_mask(geo, shape_hw=(3, 5)) + + +def test_build_domain_mask_intersects_roi() -> None: + geo = np.ones((3, 3), dtype=bool) + roi = np.zeros((3, 3), dtype=bool) + roi[1, 1] = True + mask, diags = build_domain_mask(geo, roi) + assert mask is not None + assert bool(mask[1, 1]) and not bool(mask[0, 0]) + assert diags == () + + +def test_select_flow_samples_uses_shared_grid_and_mask() -> None: + h, w = 40, 40 + flow = np.zeros((h, w, 2), dtype=np.float64) + flow[15, 15] = (0.5, 0.0) # |2*flow| = 1.0 + mask = np.ones((h, w), dtype=bool) + mask[:, :5] = False + policy = VisualPolicy(flow_on_px=0.4, flow_grid_spacing=10, flow_edge_margin=0) + samples = select_flow_samples(flow, mask, policy) + assert samples.rows.size >= 1 + assert all(c >= 5 for c in samples.cols.tolist()) + pairs = set(zip(samples.rows.tolist(), samples.cols.tolist())) + assert (15, 15) in pairs + + +def test_select_flow_samples_hysteresis_keeps_weaker_motion() -> None: + h, w = 30, 30 + flow = np.zeros((h, w, 2), dtype=np.float64) + # |2*flow| = 0.7: below on (0.8) but above off (0.5) + flow[15, 15] = (0.35, 0.0) + policy = VisualPolicy( + flow_on_px=0.8, + flow_off_px=0.5, + flow_grid_spacing=10, + flow_edge_margin=0, + ) + previously = np.zeros((h, w), dtype=bool) + previously[15, 15] = True + off = select_flow_samples(flow, None, policy, previously_on=None) + on = select_flow_samples(flow, None, policy, previously_on=previously) + assert off.rows.size == 0 + assert on.rows.size == 1 + + +def test_paint_domain_background_colours() -> None: + mask = np.zeros((4, 4), dtype=bool) + mask[1:3, 1:3] = True + canvas = paint_domain_background( + (4, 4), mask, island_bgr=(10, 20, 30), exterior_bgr=(1, 2, 3) + ) + assert tuple(canvas[0, 0]) == (1, 2, 3) + assert tuple(canvas[1, 1]) == (10, 20, 30)