From 904c6ea13add0f5960ccaa4806d0ae9b16a76873 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 16 Jul 2026 10:36:51 +0300 Subject: [PATCH] feat: activate self-hosted lidar spatial scene --- README.md | 60 +- apps/k1-viewer/README.md | 120 ++-- apps/k1-viewer/src/App.tsx | 528 ++++++++++++------ apps/k1-viewer/src/api.ts | 17 + .../k1-viewer/src/components/LandingStage.tsx | 15 +- .../src/components/RerunViewport.tsx | 23 +- apps/k1-viewer/src/messages.ts | 12 +- apps/k1-viewer/src/presentation.ts | 3 +- apps/k1-viewer/src/productModel.ts | 2 +- apps/k1-viewer/src/styles/base.css | 117 +++- apps/k1-viewer/src/styles/device.css | 30 +- apps/k1-viewer/src/styles/responsive.css | 16 +- apps/k1-viewer/src/styles/scene-windows.css | 83 +++ apps/k1-viewer/src/styles/shell.css | 139 +---- apps/k1-viewer/src/styles/spatial.css | 61 +- apps/k1-viewer/src/styles/workspaces.css | 52 +- apps/k1-viewer/src/useK1Console.ts | 18 +- .../src/workspaces/DeviceWorkspace.tsx | 43 +- apps/k1-viewer/src/workspaces/Workspaces.tsx | 43 +- docs/06_K1_LIVE_VIEWER.md | 292 +++++++--- pyproject.toml | 1 + src/k1link/viewer/rerun_bridge.py | 386 +++++++++++++ src/k1link/viewer/runtime.py | 139 ++++- src/k1link/web/app.py | 50 +- tests/test_rerun_bridge.py | 281 ++++++++++ tests/test_web_console.py | 27 + uv.lock | 97 ++++ 27 files changed, 2009 insertions(+), 646 deletions(-) create mode 100644 apps/k1-viewer/src/styles/scene-windows.css create mode 100644 src/k1link/viewer/rerun_bridge.py create mode 100644 tests/test_rerun_bridge.py diff --git a/README.md b/README.md index 08e0a45..86d8fdd 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,10 @@ or speculative writes. Current status: live proof completed on firmware 3.0.2. The Mac provisioned the K1 onto an existing LAN without LixelGO, connected to its MQTT broker, captured the scan-correlated point-cloud and pose streams, and decoded both successfully. -The repository also contains a local React control console and a Foxglove live -bridge for the verified point-cloud and trajectory topics. +The repository also contains a local React control console, an automatic Rerun +gRPC bridge for the verified point-cloud and trajectory streams, and a +self-hosted Rerun Web Viewer embedded in that console. The former Foxglove +bridge remains only as a legacy regression module. The repository now contains one narrowly gated state-changing command: `ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning @@ -88,25 +90,45 @@ inside the NODE.DC shell. It can open a compatible RRD file over HTTP(S) or a Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not use an external hosted viewer UI. -There is one important current boundary: the automatic K1 MQTT → Rerun -RRD/gRPC contract is **not implemented yet**. Starting a K1 live or replay -session updates real state and metrics, but it does not by itself populate the -embedded Rerun viewport. The scene controls for point size, gradients, -accumulation, layers, timeline and layout are presently a product UI contract; -their Rerun Blueprint/playback adapter is also still pending. No synthetic -point cloud, trajectory, camera frame or latency value is generated to conceal -these missing links. +The first K1 live session or replay in a `k1link serve` process creates one local +Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes +the resulting URL through control-plane state. Later sessions reset their +session-local scene and metrics and reuse that process-wide stream; this avoids +restarting the native listener while the embedded browser remains connected. +Unless an operator has entered a manual source, the React application assigns +that URL to the embedded viewer. The complete runtime path is K1 MQTT → raw-first evidence +capture → bounded latest-wins preview queue → reviewed protobuf/LZ4 decoders → +Rerun `Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer. -The existing Python MQTT → Foxglove pipeline remains in place as the verified -legacy visualization adapter. It still publishes `/k1/points`, `/k1/pose`, -`/k1/trajectory` and `/k1/metrics`, and preserves the measured -MQTT-receive-to-Foxglove-publish latency plus bounded latest-wins preview -dropping. It is retained for evidence, regression and diagnostics, not as the -target Control Station interface. The historical -[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records that proven path and its -timing semantics; the current frontend contract is documented in +The default Rerun blueprint shows a 12-second sliding accumulation of real point +frames. Product controls are connected for point size, intensity/height/distance +or available RGB coloring, Turbo/Viridis/Plasma/grayscale/custom palettes, +point and trajectory visibility, and the scene grid. Projection, custom +timeline transport and saved layout remain later product work. No synthetic +point cloud, trajectory, camera frame or latency value is generated. + +A powered-device checkpoint passed 80 real MQTT messages through the current +Rerun runtime: 38 point-cloud frames, 42 pose frames, 2,775 points in the last +cloud and zero decode errors. Raw panoramic camera frames remain absent. Rerun +`capture_time` is the Mac receive timestamp, not a proven K1 sensor timestamp or +photon-to-screen measurement. + +The old Foxglove implementation is retained only in +`src/k1link/viewer/foxglove_bridge.py` and its regression tests. The current +live/replay runtime does not start it or use TCP 8765. The +[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and +its timing/security boundaries; the frontend contract is documented in [`apps/k1-viewer/README.md`](apps/k1-viewer/README.md). +The FastAPI application and credential endpoint bind to loopback, but the Rerun +gRPC server currently binds TCP 9876 on all network interfaces even though its +reported source URL contains `127.0.0.1`. It has no connector-level +authentication or TLS. Use it only on a trusted laboratory LAN, do not expose +9876 to the public Internet or a cellular WAN, and add an authenticated secure +proxy before any remote deployment. Stopping acquisition keeps the local scene +server and its URL available for the next session. Stop `k1link serve` to close +the listener and release its retained memory. + `doctor` is intentionally non-invasive. It checks the local Python environment and reports external tools; it does not request Bluetooth permission, scan the LAN, touch the K1, alter Homebrew, or change capture permissions. @@ -147,7 +169,7 @@ present. - [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md) - [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md) - [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md) -- [Live console and Foxglove runbook](docs/06_K1_LIVE_VIEWER.md) +- [Live console and embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md) - [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md) - [Session manifest schema](schemas/session-manifest.schema.json) - [Reference input provenance](docs/reference/README.md) diff --git a/apps/k1-viewer/README.md b/apps/k1-viewer/README.md index 35e931b..c803ecd 100644 --- a/apps/k1-viewer/README.md +++ b/apps/k1-viewer/README.md @@ -7,8 +7,9 @@ device adapter, но структура интерфейса от него не Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer. Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не -переход во внешний облачный интерфейс. Сам браузерный компонент, однако, должен -получить совместимый источник RRD или Rerun gRPC. +переход во внешний облачный интерфейс. При запуске K1 live/replay backend сам +создаёт Rerun gRPC/proxy source; ручной адрес нужен только для другого Rerun +потока или совместимой RRD-записи. ## Текущее состояние @@ -18,10 +19,10 @@ device adapter, но структура интерфейса от него не | Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. | | K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. | | K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. | -| Legacy Foxglove adapter | Реализован | Текущий Python runtime публикует декодированные K1 данные в локальный Foxglove WebSocket. Это проверенный диагностический backend, а не продуктовый UI. | -| Встроенный Rerun Viewer | Реализован | Открывает назначенный RRD по HTTP(S) или совместимый Rerun gRPC/proxy source внутри Control Station. | -| Автоматический MQTT → Rerun | **Не реализован** | Запуск K1 live/replay пока не создаёт RRD и не подаёт данные во встроенный Rerun viewport. Нужен отдельный stream adapter. | -| Контролы сцены → Rerun Blueprint | **Не реализованы** | Размер точек, палитра, накопление, слои, проекция, timeline и компоновка пока являются честным UI-контрактом и состоянием текущего сеанса. | +| Автоматический MQTT → Rerun | Реализован | Первый live/replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие сессии сбрасывают сцену и метрики и переиспользуют его. | +| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. | +| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория и сетка. Проекция, собственный timeline и сохранённые layout-профили ещё не подключены. | +| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. | | Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. | Приложение не генерирует демонстрационное облако, траекторию, кадры или @@ -31,28 +32,30 @@ device adapter, но структура интерфейса от него не ## Архитектура данных ```text -NODE.DC Control Station в браузере -├── control plane -│ └── REST /api/* + WebSocket /api/events -│ └── FastAPI на 127.0.0.1:8000 -│ ├── CoreBluetooth: discovery и reviewed Wi-Fi provisioning -│ └── live/replay session runtime -│ -├── текущий K1 preview path — legacy adapter -│ └── K1 MQTT :1883, read-only -│ ├── raw .k1mqtt + metadata + SHA-256 сохраняются первыми -│ └── protobuf/LZ4 decoder -│ └── Foxglove bridge → локальный WebSocket -│ -└── продуктовая пространственная сцена - └── встроенный @rerun-io/web-viewer - └── RRD URL или Rerun gRPC/proxy source +K1 MQTT :1883, read-only +└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми + └── bounded latest-wins preview queue (32 сообщения) + └── проверенный protobuf/LZ4 decoder + └── Rerun Points3D + Transform3D + LineStrips3D + └── gRPC/proxy TCP 9876 + └── rerun_grpc_url в REST/WebSocket state + └── встроенный @rerun-io/web-viewer + └── пространственная сцена NODE.DC + +React Control Station ←→ REST /api/* + WebSocket /api/events + FastAPI на 127.0.0.1:8000 + CoreBluetooth + live/replay runtime ``` -Между декодером K1 и Rerun Viewer сейчас нет автоматической стрелки. Поля -`foxglove_ws_url` и `foxglove_viewer_url` ещё присутствуют в API только потому, -что Python runtime продолжает поднимать проверенный legacy adapter. Пункт -управления не использует их как основную поверхность визуализации. +В момент готовности `RerunBridge` backend публикует адрес вида +`rerun+http://127.0.0.1:9876/proxy`. Frontend автоматически назначает его сцене, +если оператор не указал ручной source. После остановки приёма URL и встроенный +viewer остаются активны, а следующая сессия сбрасывает session-local геометрию, +траекторию и метрики и использует тот же listener. Он закрывается вместе с +процессом `k1link serve`. + +Поля `foxglove_ws_url` и `foxglove_viewer_url` пока остаются в API как +совместимость со старым контрактом, но текущий runtime держит их пустыми. ## Фиксированная оболочка @@ -70,8 +73,10 @@ Shell собран из локальных NODE.DC UI packages и сохраня Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя, blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в -Control Station. Это ещё не означает, что все продуктовые контролы уже связаны -с API Rerun: текущая граница явно показана статусом `Интерфейс готов`. +Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по +умолчанию накопление, видимость облака и траектории и сетка связаны с backend и +Rerun Blueprint. Кнопки собственного timeline, смена 2D/3D/карты и сохранение +layout пока остаются интерфейсным контрактом. ## Архитектурные разделы @@ -93,7 +98,7 @@ Control Station. Это ещё не означает, что все продук ## Установка, проверка, сборка и запуск -Требуются Node.js 20 или новее, `uv` и соседний checkout +Требуются Node.js 20.19+ либо 22.12+, `uv` и соседний checkout `NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через локальные `file:` пути. Python устанавливается только в `.venv` репозитория. @@ -142,18 +147,22 @@ Vite слушает `http://127.0.0.1:5173` и проксирует `/api` и `/ 6. Запустить live-приём по определённому адресу K1 либо replay локального `.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и останавливается подтверждённым двойным нажатием кнопки устройства. -7. Состояние, частота, число точек, задержка и пропуски preview появятся только - после прихода реальных данных. +7. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в + state. Ручной source вводить не требуется. +8. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория, + частота, число точек, задержка и пропуски preview появятся после прихода + сообщений K1. -Этот путь запускает существующий MQTT/Foxglove runtime. Чтобы увидеть тот же -живой K1 поток во встроенной пространственной сцене, нужно сначала реализовать и -запустить MQTT→Rerun stream adapter; одного запуска live-сессии сейчас -недостаточно. +В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений: +38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и +0 ошибок декодирования. ## Источники Rerun -В **Наблюдение → Пространственная сцена → Источник** принимается URL, который -понимает `@rerun-io/web-viewer` версии, зафиксированной в `package.json`: +Live/replay runtime автоматически назначает первый URL из примера. В +**Наблюдение → Пространственная сцена → Источник** можно оставить ручное поле +пустым либо указать другой адрес, который понимает `@rerun-io/web-viewer` +зафиксированной в `package.json` версии: ```text rerun+http://127.0.0.1:9876/proxy @@ -165,14 +174,23 @@ https://example.internal/recording.rrd proxy; - `http(s)://…/recording.rrd` — RRD-запись по HTTP(S); - версия RRD должна быть совместима с версией Web Viewer; -- URL хранится только в состоянии текущей страницы; +- ручной URL хранится только в состоянии текущей страницы и имеет приоритет над + автоматическим source; - ошибка запуска показывается в viewport, без подстановки фиктивных данных. После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в -оболочку. Продуктовые настройки отображения, custom timeline, переключатель -2D/3D/карты и сохранение RBL пока не вызывают Blueprint/playback API. Черновик -компоновки фиксируется только в памяти текущей страницы и не записывается на -диск. +оболочку. Backend принимает и применяет к Rerun размер точки `0.5–12.0`, режимы +цвета `intensity`, `height`, `distance`, `rgb`, `class`, палитры Turbo, Viridis, +Plasma, grayscale и custom, накопление `0–120` секунд, а также видимость облака, +траектории и сетки. Значение по умолчанию — 12 секунд истории реальных кадров. +Для текущего firmware-3 `lio_pcl` доказана только интенсивность из младшего +байта `rgbi`; RGB используется лишь когда он действительно присутствует в +декодированном формате. Custom/class сейчас означает выбранный сплошной цвет, +а не готовую семантическую классификацию. + +Custom timeline, переключатель 2D/3D/карты, семантические слои и сохранение RBL +пока не вызывают Blueprint/playback API. Черновик компоновки фиксируется только +в памяти текущей страницы и не записывается на диск. ## Контракт локального API @@ -185,12 +203,14 @@ https://example.internal/recording.rrd | `POST` | `/api/session/live` | Опционально `{ "host", "duration_seconds" }`. | | `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }`. | | `POST` | `/api/session/stop` | Остановка активного источника. | +| `POST` | `/api/viewer/settings` | Размер/цвет точек, накопление, видимость облака, траектории и сетки. | | `WS` | `/api/events` | Периодические snapshots для live UI. | `/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`, -`selected_device_id`, `k1_ip`, `source_mode`, `metrics` и legacy-поля -`foxglove_ws_url`/`foxglove_viewer_url`. OpenAPI доступен по `/api/docs`. +`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и +`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются +пустыми. OpenAPI доступен по `/api/docs`. ## Карта исходников frontend @@ -211,6 +231,12 @@ https://example.internal/recording.rrd ## Safety и чувствительные данные - Control Station и credential endpoint доступны только на loopback. +- Исключение: Rerun gRPC/proxy на TCP 9876 сейчас слушает все сетевые интерфейсы, + хотя автоматически возвращаемый URL содержит `127.0.0.1`. На нём нет + connector-level authentication или TLS. Этот порт допустим только в доверенной + лабораторной LAN; его нельзя пробрасывать в публичный Интернет или cellular + WAN без аутентифицированного TLS reverse proxy. Listener намеренно остаётся + активным между сессиями; для его закрытия нужно остановить `k1link serve`. - Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body, очищается после успешного ответа и не сохраняется в URL/local storage. - BLE-подключение выполняет только отдельно рассмотренную provisioning-запись; @@ -219,6 +245,12 @@ https://example.internal/recording.rrd физического сканирования остаются за кнопкой K1. - Live-сессии сначала сохраняют сырые сообщения, затем формируют preview. При перегрузке preview может быть отброшен, raw evidence сохраняется. +- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно + накопления viewer использует session-local `stream_time`. `capture_time` — не + доказанный timestamp сенсора K1 и не photon-to-screen latency. +- Панорамный камерный поток в наблюдавшихся MQTT report topics отсутствует; + текущая Rerun-сцена содержит только облако точек и позу/траекторию, а + операционные метрики отображаются внешней оболочкой Control Station. - `sessions/` игнорируется Git и может содержать адреса, идентификаторы, траекторию и карту помещения. В репозиторий попадают только redacted manifests и безопасная документация. diff --git a/apps/k1-viewer/src/App.tsx b/apps/k1-viewer/src/App.tsx index ee54567..f11b90b 100644 --- a/apps/k1-viewer/src/App.tsx +++ b/apps/k1-viewer/src/App.tsx @@ -1,10 +1,11 @@ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AdminNavigationPanel, AppHeader, ApplicationPanel, ApplicationShell, Button, + Checker, ColorField, ControlRow, HeaderAvatar, @@ -17,7 +18,6 @@ import { RangeControl, Select, StatusBadge, - Switch, TextField, Window, WindowFooterActions, @@ -26,6 +26,7 @@ import { } from "@nodedc/ui-react"; import { LandingStage } from "./components/LandingStage"; +import type { ViewerSettings } from "./api"; import { rootById, roots, @@ -44,6 +45,9 @@ import { import { useK1Console } from "./useK1Console"; import { DeviceWorkspace } from "./workspaces/DeviceWorkspace"; import { WorkspaceRenderer } from "./workspaces/Workspaces"; +import "./styles/scene-windows.css"; + +type SceneToolWindowId = "sources" | "display" | "layers" | "layout"; const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [ { value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" }, @@ -61,6 +65,46 @@ const paletteOptions: Array<{ value: PointPalette; label: string; description: s { value: "custom", label: "Свой цвет", description: "Один назначенный цвет" }, ]; +function toViewerSettings(settings: SceneSettings): ViewerSettings { + return { + point_size: settings.pointSize, + color_mode: settings.colorMode, + palette: settings.palette, + custom_color: settings.customColor, + accumulation_seconds: settings.accumulationSeconds, + show_points: settings.showPoints, + show_trajectory: settings.showTrajectory, + show_grid: settings.showGrid, + }; +} + +function mergeViewerSettings( + current: SceneSettings, + remote: ViewerSettings, +): SceneSettings { + const next = { + ...current, + pointSize: remote.point_size, + colorMode: remote.color_mode, + palette: remote.palette, + customColor: remote.custom_color, + accumulationSeconds: remote.accumulation_seconds, + showPoints: remote.show_points, + showTrajectory: remote.show_trajectory, + showGrid: remote.show_grid, + }; + const unchanged = + current.pointSize === next.pointSize && + current.colorMode === next.colorMode && + current.palette === next.palette && + current.customColor === next.customColor && + current.accumulationSeconds === next.accumulationSeconds && + current.showPoints === next.showPoints && + current.showTrajectory === next.showTrajectory && + current.showGrid === next.showGrid; + return unchanged ? current : next; +} + export default function App() { const console = useK1Console(); const workspace = useApplicationWorkspace({ @@ -74,6 +118,7 @@ export default function App() { const [sourceWindowOpen, setSourceWindowOpen] = useState(false); const [displayWindowOpen, setDisplayWindowOpen] = useState(false); const [layerInspectorOpen, setLayerInspectorOpen] = useState(false); + const [sceneWindowOrder, setSceneWindowOrder] = useState([]); const [layoutWindowOpen, setLayoutWindowOpen] = useState(false); const [layoutName, setLayoutName] = useState("Операторская сцена"); const [layoutDraftSaved, setLayoutDraftSaved] = useState(false); @@ -83,6 +128,48 @@ export default function App() { const currentRoot = rootById(activeRoot); const activeDefinition = workspaceById(workspace.activeView); const rootWorkspaces = workspacesForRoot(activeRoot); + const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null; + const automaticSourceUrl = console.state?.rerun_grpc_url?.trim() ?? ""; + const effectiveSourceUrl = sourceUrl || automaticSourceUrl; + + useEffect(() => { + const remote = console.state?.viewer_settings; + if (!remote) return; + setSceneSettings((current) => mergeViewerSettings(current, remote)); + if (!displayWindowOpen) { + setDisplayDraft((current) => mergeViewerSettings(current, remote)); + } + }, [console.state?.viewer_settings, displayWindowOpen]); + + const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => { + setSceneWindowOrder((current) => [ + ...current.filter((candidate) => candidate !== windowId), + windowId, + ]); + }, []); + + const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => { + if (windowId === "sources") setSourceWindowOpen(false); + if (windowId === "display") setDisplayWindowOpen(false); + if (windowId === "layers") setLayerInspectorOpen(false); + if (windowId === "layout") setLayoutWindowOpen(false); + setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId)); + }, []); + + useEffect(() => { + if (!activeSceneWindow) return; + + const closeActiveWindow = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.defaultPrevented) return; + if (document.querySelector(".nodedc-dropdown-surface")) return; + if (document.querySelector('.nodedc-overlay[data-placement="center"]')) return; + event.preventDefault(); + closeSceneWindow(activeSceneWindow); + }; + + document.addEventListener("keydown", closeActiveWindow); + return () => document.removeEventListener("keydown", closeActiveWindow); + }, [activeSceneWindow, closeSceneWindow]); const selectRoot = (rootId: RootId) => { setActiveRoot(rootId); @@ -100,11 +187,41 @@ export default function App() { const openSource = () => { setSourceDraft(sourceUrl); setSourceWindowOpen(true); + activateSceneWindow("sources"); }; const openDisplay = () => { setDisplayDraft(sceneSettings); setDisplayWindowOpen(true); + activateSceneWindow("display"); + }; + + const openLayers = () => { + setLayerInspectorOpen(true); + activateSceneWindow("layers"); + }; + + const openLayout = () => { + setLayoutDraftSaved(false); + setLayoutWindowOpen(true); + activateSceneWindow("layout"); + }; + + const applyDisplaySettings = async () => { + const applied = await console.updateViewerSettings(toViewerSettings(displayDraft)); + if (applied) setSceneSettings(displayDraft); + }; + + const applyScenePatch = async (patch: Partial) => { + const previous = sceneSettings; + const next = { ...sceneSettings, ...patch }; + setSceneSettings(next); + setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next)); + const applied = await console.updateViewerSettings(toViewerSettings(next)); + if (!applied) { + setSceneSettings(previous); + if (!displayWindowOpen) setDisplayDraft(previous); + } }; const contentActions = useMemo(() => { @@ -122,17 +239,14 @@ export default function App() { actions.push( { label: "Настроить источник", icon: "network", onClick: openSource }, { label: "Настроить отображение", icon: "sliders", onClick: openDisplay }, - { label: "Открыть слои", icon: "list", onClick: () => setLayerInspectorOpen(true) }, + { label: "Открыть слои", icon: "list", onClick: openLayers }, ); } actions.push({ label: "Сохранить компоновку", icon: "save", - onClick: () => { - setLayoutDraftSaved(false); - setLayoutWindowOpen(true); - }, + onClick: openLayout, }); return actions; }, [activeDefinition?.kind, console, sceneSettings, sourceUrl]); @@ -231,8 +345,8 @@ export default function App() { {phaseLabel(console.state?.phase)} ) : activeDefinition.kind === "spatial" ? ( - - {sourceUrl ? "Источник назначен" : "Без источника"} + + {effectiveSourceUrl ? "Источник назначен" : "Без источника"} ) : ( Интерфейс готов @@ -242,20 +356,22 @@ export default function App() { onClose={workspace.closeView} > {activeDefinition.kind === "device" ? ( - + openView("spatial-scene")} + /> ) : ( setLayerInspectorOpen(true), + openLayers, }} /> )} @@ -265,10 +381,19 @@ export default function App() { setSourceWindowOpen(false)} + title="Источники" + subtitle="Потоки и записи пространственной сцены" + placement="end" + draggable + closeOnBackdrop={false} + closeOnEscape={false} + lockBodyScroll={false} + trapFocus={false} + className="scene-tool-window scene-tool-window--sources" + data-scene-window="sources" + data-scene-active={activeSceneWindow === "sources" ? "true" : undefined} + onPointerDown={() => activateSceneWindow("sources")} + onClose={() => closeSceneWindow("sources")} footer={ } > -
-
- -
- Встроенный открытый визуализатор -

- Источник открывается внутри интерфейса, без перехода во внешний сервис. Версия - RRD должна совпадать с версией визуального движка. -

-
-
- setSourceDraft(event.target.value)} - spellCheck={false} - placeholder="rerun+http://127.0.0.1:9876/proxy или https://…/recording.rrd" - description="Текущий локальный MQTT-поток пока требует отдельного адаптера потока Rerun." - /> -
-
rerun+http://…/proxyЖивой gRPC источник
-
http://…/recording.rrdЗапись по HTTP
-
https://…/recording.rrdУдалённая запись
-
-
+ + + +