Author SHA1 Message Date
DCCONSTRUCTIONS 7aeea4ed81 perf(lab): retain spatial scenes during replay 2026-08-27 15:19:00 +03:00
DCCONSTRUCTIONS bf76304e00 perf(lab): stream sealed spatial playback tracks 2026-08-27 15:18:45 +03:00
DCCONSTRUCTIONS b70dc4298a docs(perception): accept integrated TGS graph load gate 2026-08-27 12:01:27 +03:00
DCCONSTRUCTIONS f153d671d3 fix(perception): close integrated shadow packaging 2026-08-27 11:39:48 +03:00
DCCONSTRUCTIONS 7720594790 feat(perception): add integrated TGS graph shadow gate 2026-08-27 11:27:28 +03:00
DCCONSTRUCTIONS 296cf610cd fix(lab): preserve replay view and restore SLAM semantics 2026-08-26 23:37:05 +03:00
DCCONSTRUCTIONS 46cef8df17 docs(perception): record full TGS shadow acceptance 2026-08-26 23:07:16 +03:00
DCCONSTRUCTIONS d722b0f82b feat(lab): publish full TGS shadow evidence 2026-08-26 23:07:06 +03:00
DCCONSTRUCTIONS 40c850b167 feat(perception): add full TGS shadow runner 2026-08-26 21:59:39 +03:00
DCCONSTRUCTIONS 6544d9e918 feat(lab): publish fail-closed TGS evidence 2026-08-26 21:32:35 +03:00
DCCONSTRUCTIONS 67d5d6fa05 docs(perception): record gravity-aligned TGS evidence 2026-08-26 19:51:50 +03:00
DCCONSTRUCTIONS 941fb89616 feat(perception): add gravity-aligned TGS evidence gate 2026-08-26 19:47:23 +03:00
DCCONSTRUCTIONS 989a3ce28c docs(perception): reject direct TRAVEL AOS compatibility 2026-08-26 19:34:10 +03:00
DCCONSTRUCTIONS 28d5297237 feat(perception): add TRAVEL RAVNOVES compatibility probe 2026-08-26 19:25:19 +03:00
DCCONSTRUCTIONS b0738438d4 docs(perception): record TRAVEL upstream qualification 2026-08-26 19:06:56 +03:00
DCCONSTRUCTIONS 67578f4ce1 feat(perception): stage TRAVEL qualification 2026-08-26 18:53:26 +03:00
DCCONSTRUCTIONS 83ef98e9e5 docs(perception): record GSeg3D qualification rejection 2026-08-26 18:53:25 +03:00
DCCONSTRUCTIONS 316f0d7f85 fix(perception): match merged ROS test layout 2026-08-26 18:44:41 +03:00
DCCONSTRUCTIONS 2e7abbc56b fix(perception): source ROS setup outside nounset 2026-08-26 18:42:02 +03:00
DCCONSTRUCTIONS b445745d27 fix(perception): run Worker build interactively 2026-08-26 18:32:22 +03:00
DCCONSTRUCTIONS 1cc87e1836 fix(perception): isolate Worker Docker credentials 2026-08-26 18:29:33 +03:00
DCCONSTRUCTIONS aed59030b4 feat(perception): stage GSeg3D qualification 2026-08-26 18:27:39 +03:00
DCCONSTRUCTIONS 2e20ca7777 feat(simulation): load combined rover collision 2026-08-26 17:50:37 +03:00
DCCONSTRUCTIONS ae13ca5946 feat(simulation): prefer rover world terrain 2026-08-26 17:17:48 +03:00
DCCONSTRUCTIONS 1b6131904a docs(perception): define traversability evaluation sequence 2026-08-26 16:56:15 +03:00
DCCONSTRUCTIONS 7fa2bfe52d fix(simulation): reset camera input on layer switch 2026-08-26 16:45:05 +03:00
DCCONSTRUCTIONS 1696b4742b fix(simulation): keep runtime stable on settings save 2026-08-26 16:15:37 +03:00
DCCONSTRUCTIONS ac1dc7d930 fix(lab): show only actionable obstacle boxes 2026-08-26 16:09:32 +03:00
DCCONSTRUCTIONS a98ad929d9 fix(simulation): resume live provider builds 2026-08-26 15:46:46 +03:00
DCCONSTRUCTIONS 5a9738d6aa feat(lab): project system obstacles into fisheye 2026-08-26 15:31:06 +03:00
DCCONSTRUCTIONS 30227b661f fix(runtime): watchdog canonical liveness 2026-08-26 15:26:17 +03:00
DCCONSTRUCTIONS 4bcc35c1a0 docs(simulation): record collision publication contract 2026-08-26 15:21:22 +03:00
DCCONSTRUCTIONS a65ae9c285 feat(simulation): optimize and persist collision viewing 2026-08-26 15:18:34 +03:00
DCCONSTRUCTIONS 5e97fa293f fix(simulation): bound rendering and expose camera inversion 2026-08-26 14:30:25 +03:00
DCCONSTRUCTIONS aba06086ac fix(lab): keep split-view controls clickable 2026-08-26 14:24:24 +03:00
DCCONSTRUCTIONS 424374263e fix(simulation): align LCC layers in PlayCanvas world 2026-08-26 14:20:40 +03:00
DCCONSTRUCTIONS 906a6ce37b docs(mission): record accepted M4.8R3 shadow 2026-08-26 14:13:29 +03:00
DCCONSTRUCTIONS 1496184167 perf(perception): require exact critical-range persistence 2026-08-26 13:59:26 +03:00
DCCONSTRUCTIONS 9561a068ab chore(lab): classify M4.8R3 sealed evidence 2026-08-26 13:47:28 +03:00
DCCONSTRUCTIONS 9e686b3311 build(worker): pin persistent low-step profile 2026-08-26 13:42:43 +03:00
DCCONSTRUCTIONS 936ee479ed feat(lab): visualize M4.8R3 system occupancy 2026-08-26 13:40:24 +03:00
DCCONSTRUCTIONS b82a97fe8b feat(lab): seal M4.8R3 occupancy shadows 2026-08-26 13:39:54 +03:00
DCCONSTRUCTIONS 90bdc27785 feat(perception): confirm persistent sparse low steps 2026-08-26 13:39:01 +03:00
DCCONSTRUCTIONS 0b01bb7775 fix: report missing collision without stale CPU claim 2026-08-26 13:19:30 +03:00
DCCONSTRUCTIONS b688347778 refactor(lab): admit versioned graph ledgers 2026-08-26 13:10:11 +03:00
DCCONSTRUCTIONS 252cbc8495 perf(perception): bound low-step component fanout 2026-08-26 13:10:02 +03:00
DCCONSTRUCTIONS 048716b3b3 build(worker): package M4.8R3 shadow release 2026-08-26 12:41:42 +03:00
DCCONSTRUCTIONS 343ddeac2d feat(perception): add occupied-only low-step shadow 2026-08-26 12:39:54 +03:00
DCCONSTRUCTIONS 5983883ffe docs(perception): record M4.8R2 occupancy gate 2026-08-26 11:52:09 +03:00
DCCONSTRUCTIONS 6290c75210 feat(control-station): present static occupancy qualification 2026-08-26 11:52:03 +03:00
DCCONSTRUCTIONS 3c81c39a1c feat(perception): qualify conservative static occupancy 2026-08-26 11:51:56 +03:00
DCCONSTRUCTIONS 932c5216dc feat(control-station): review native raw fisheye risk cases 2026-08-26 10:15:56 +03:00
DCCONSTRUCTIONS f9a76fed0e feat(lab): seal native risk review evidence 2026-08-26 10:15:49 +03:00
DCCONSTRUCTIONS c05db1fbe0 feat(perception): mine native fisheye risk cases 2026-08-26 10:15:43 +03:00
DCCONSTRUCTIONS 385082c8ca fix: recover failed Gaussian project builds 2026-08-26 09:34:23 +03:00
DCCONSTRUCTIONS cb1e1494af test(perception): qualify native RF-DETR graph shadow 2026-08-26 02:23:26 +03:00
DCCONSTRUCTIONS d943ec853c feat: add Gaussian simulation workspace 2026-08-26 02:09:44 +03:00
DCCONSTRUCTIONS b111406cf8 feat(perception): integrate native RF-DETR shadow provider 2026-08-26 02:06:16 +03:00
DCCONSTRUCTIONS 28effdde23 feat(perception): build native raw RF-DETR engine 2026-08-26 02:06:03 +03:00
DCCONSTRUCTIONS 2920fc7579 feat(simulation): upload real gaussian source bundles 2026-08-26 00:54:28 +03:00
DCCONSTRUCTIONS 147a623df9 docs(perception): record RF-DETR COCO parity evidence 2026-08-26 00:45:32 +03:00
DCCONSTRUCTIONS 62c8ed421a feat(perception): add RF-DETR upstream parity gate 2026-08-26 00:45:17 +03:00
DCCONSTRUCTIONS be079bf18f fix(simulation): require gaussian runtime provenance 2026-08-26 00:35:05 +03:00
DCCONSTRUCTIONS 73fbfddf6c feat(simulation): add portable gaussian provider connector 2026-08-26 00:24:53 +03:00
DCCONSTRUCTIONS 3ff3b9b587 feat(lab): publish M4.8T quality evidence 2026-08-25 23:44:27 +03:00
DCCONSTRUCTIONS 9a956c318a feat(perception): qualify M4.8T semantic identity 2026-08-25 23:44:17 +03:00
DCCONSTRUCTIONS 209d0bfc26 feat(laboratory): publish repeated M48S load envelope 2026-08-25 22:31:27 +03:00
DCCONSTRUCTIONS f50e0077c5 fix(service): supervise canonical Mission Core lifecycle 2026-08-25 21:58:22 +03:00
DCCONSTRUCTIONS fb5bf943c9 fix(perception): isolate recorded decode from realtime admission 2026-08-25 21:58:12 +03:00
DCCONSTRUCTIONS bab0bb2df2 feat(laboratory): publish M48S load envelope 2026-08-25 20:36:26 +03:00
DCCONSTRUCTIONS e21332b9e1 feat(perception): measure M48S load envelope 2026-08-25 20:36:21 +03:00
DCCONSTRUCTIONS 078b8421a6 feat(laboratory): compare M48S runtime hardening 2026-08-25 19:38:11 +03:00
DCCONSTRUCTIONS 84624ae3ea feat(perception): prewarm RF-DETR before source admission 2026-08-25 19:09:53 +03:00
DCCONSTRUCTIONS 477886d100 fix(perception): keep cyclic GC outside realtime loop 2026-08-25 18:50:38 +03:00
DCCONSTRUCTIONS 1d706dd169 fix(perception): correlate GC with active stages 2026-08-25 18:44:57 +03:00
DCCONSTRUCTIONS d1e6e42cf4 chore(perception): trace Python GC pauses 2026-08-25 18:38:33 +03:00
DCCONSTRUCTIONS a0152f371e feat(perception): trace M4.8S pipeline latency 2026-08-25 18:21:57 +03:00
DCCONSTRUCTIONS 5b07372791 fix(perception): release processed source payloads 2026-08-25 18:04:26 +03:00
DCCONSTRUCTIONS b5080671c9 fix(lab): stabilize replay and densify LiDAR overlay 2026-08-25 17:51:50 +03:00
DCCONSTRUCTIONS 15be698097 feat(ui): add M4.8S replay with LiDAR overlay 2026-08-25 16:44:52 +03:00
DCCONSTRUCTIONS 3679e43fe3 feat(lab): publish M4.8S fixed-class detector replay 2026-08-25 16:44:35 +03:00
DCCONSTRUCTIONS 05590867b7 feat(perception): seal M4.8S reference graph replay 2026-08-25 16:44:26 +03:00
DCCONSTRUCTIONS 33cef2fdea feat(perception): evaluate fixed-class detector candidates 2026-08-25 16:44:15 +03:00
DCCONSTRUCTIONS 6276bbf324 feat(perception): define semantic object understanding 2026-08-25 16:43:47 +03:00
DCCONSTRUCTIONS 1084984da2 refactor(ui): reuse canonical graph for M4.8 regression 2026-08-25 00:08:33 +03:00
DCCONSTRUCTIONS 669bb64423 feat(lab): bind M4.8 regression to reference graph 2026-08-25 00:07:58 +03:00
DCCONSTRUCTIONS eb416ff80a docs(lab): freeze M4.8 viewer and regression canon 2026-08-24 22:38:54 +03:00
DCCONSTRUCTIONS 4fa1669ab7 feat(lab): publish M4.8 assisted regression evidence 2026-08-24 22:38:26 +03:00
DCCONSTRUCTIONS 4fa6597b18 feat(ui): stabilize shared M4.8 review viewer 2026-08-24 22:36:08 +03:00
DCCONSTRUCTIONS 992c5a8b74 fix(lab): make recorded replay seek-safe 2026-08-24 02:15:30 +03:00
DCCONSTRUCTIONS 7400fe2fd7 fix(lab): preserve explicit replay seeks 2026-08-24 00:41:29 +03:00
DCCONSTRUCTIONS 8877ee947f fix(lab): make replay pause authoritative 2026-08-24 00:12:49 +03:00
DCCONSTRUCTIONS a7caed70c1 fix(lab): stream synchronized replay fragments 2026-08-23 23:36:04 +03:00
DCCONSTRUCTIONS a55ef475d9 fix(lab): restore semantic overlay in M4.7 2026-08-23 22:40:07 +03:00
DCCONSTRUCTIONS 34ed8a0f5f feat(lab): publish M4.7 Worker graph evidence 2026-08-23 22:00:46 +03:00
DCCONSTRUCTIONS 64860be4ae bind M4.7 graph results to Worker runtime 2026-08-23 20:57:20 +03:00
DCCONSTRUCTIONS 4ac07f7671 compare M4.7 threat parity by component identity 2026-08-23 20:39:16 +03:00
DCCONSTRUCTIONS bcda5d001a defer optional telemetry dependencies in perception graph 2026-08-23 20:29:14 +03:00
DCCONSTRUCTIONS cbbaea9148 accept empty Docker port bindings in M4.7 preflight 2026-08-23 20:21:43 +03:00
DCCONSTRUCTIONS 3d3c0eced2 isolate M4.7 worker shadow runtime 2026-08-23 20:19:13 +03:00
DCCONSTRUCTIONS d50d3bb3d3 Добавление канонического графа M4.7 2026-08-23 20:03:40 +03:00
DCCONSTRUCTIONS 51bb1369eb Стабилизация синхронного LAB-воспроизведения 2026-08-23 18:56:06 +03:00
DCCONSTRUCTIONS a3fc13ad63 Обновление эталона защиты контура K1 2026-08-23 16:24:38 +03:00
DCCONSTRUCTIONS c041a569f6 Стабилизация подключения K1 после сна и ожидания Bluetooth 2026-08-23 16:24:05 +03:00
DCCONSTRUCTIONS 5ebc9fc27c Стабилизация отображения камеры при повторном подключении 2026-08-23 15:33:18 +03:00
DCCONSTRUCTIONS 99eafaf02d Сохранение индикации переподключения Quick Connect 2026-08-23 15:33:11 +03:00
DCCONSTRUCTIONS 03830cd15e Исключение ложного переподключения к выведенному K1 2026-08-23 14:12:57 +03:00
DCCONSTRUCTIONS 6e7428a116 Безопасное завершение калибровки при отключении K1 2026-08-23 13:55:28 +03:00
DCCONSTRUCTIONS 3e5aedd74d Диагностика запуска live-камеры в интерфейсе 2026-08-23 13:54:21 +03:00
DCCONSTRUCTIONS 687c050c28 Устранение гонки запуска live-камеры 2026-08-23 12:47:54 +03:00
DCCONSTRUCTIONS 4e00e804f3 Диагностика допуска правой камеры в интерфейс 2026-08-23 12:21:23 +03:00
DCCONSTRUCTIONS 456141a3f3 Корректное ожидание подтверждения Bridge 2026-08-23 12:14:56 +03:00
DCCONSTRUCTIONS 7d911b4b1f Стабилизация восстановления Bridge и Quick Connect 2026-08-23 10:45:53 +03:00
DCCONSTRUCTIONS 71c8b043c9 Защита Bridge и камеры K1 при переподключении 2026-08-23 01:25:25 +03:00
DCCONSTRUCTIONS 0752e5c6bf Стабилизация переключения Bridge и Quick Connect
Безопасно восстанавливает управляющее подключение и локальные checkpoint без повторных команд сканеру.

Добавляет PCAP/Bridge guardrails и регрессионные проверки одношагового переподключения.

Известный дефект: после второго подключения интерфейс не присоединяется к новой генерации preview правой камеры. В живой Quick Connect-сессии STOP был принят, но READY не подтвердился до таймаута; автоматический повтор STOP запрещён.
2026-08-23 00:15:28 +03:00
DCCONSTRUCTIONS 1001a31638 fix(k1): restore low-latency live recovery path 2026-08-22 16:17:27 +03:00
DCCONSTRUCTIONS 85035fa07b fix(k1): harden live handoff and camera recovery 2026-08-22 13:09:24 +03:00
DCCONSTRUCTIONS eaad9deda1 fix(k1): stabilize live recovery and media admission 2026-08-22 00:03:01 +03:00
DCCONSTRUCTIONS 7217244886 Стабильное подключение в идеальных условиях K1 2026-08-21 12:36:50 +03:00
DCCONSTRUCTIONS c7843a3c7e chore(k1): checkpoint connection recovery work 2026-08-21 08:12:22 +03:00
DCCONSTRUCTIONS a3138f3d71 fix(k1): retire terminal control before read-only recovery 2026-08-20 14:37:00 +03:00
DCCONSTRUCTIONS d01be34cac fix(k1): expose exact supervisor recovery verify 2026-08-20 14:04:55 +03:00
DCCONSTRUCTIONS ac4a00c67d fix(k1): cease reset zero-dispatch checkpoint 2026-08-20 13:16:56 +03:00
DCCONSTRUCTIONS aee60f12b9 fix(k1): admit live rerun only after real data 2026-08-20 12:54:55 +03:00
DCCONSTRUCTIONS 321cde70e8 fix(k1): settle same-process standby checkpoint 2026-08-14 21:32:26 +03:00
DCCONSTRUCTIONS 986ea69610 fix(k1): allow explicit reset after terminal start fault 2026-08-14 21:01:52 +03:00
DCCONSTRUCTIONS 72a2f37c03 fix(k1): use neutral process copy 2026-08-14 20:03:13 +03:00
DCCONSTRUCTIONS 4a24275ddc fix(k1): close verified stop checkpoint gap 2026-08-14 19:46:51 +03:00
DCCONSTRUCTIONS 17a07355cf fix(k1): keep reconnect feedback in place 2026-08-14 19:21:11 +03:00
DCCONSTRUCTIONS cd8a1045c4 fix(k1): keep live viewer on stream edge 2026-08-14 18:20:25 +03:00
DCCONSTRUCTIONS 3235ba7b1c fix(k1): fence start endpoint handoff 2026-08-14 17:40:07 +03:00
DCCONSTRUCTIONS 9e6b5b403a fix(k1): refresh stale endpoint before start 2026-08-14 16:27:03 +03:00
DCCONSTRUCTIONS 9ef115d3b4 fix(k1): admit start after reopened standby 2026-08-14 15:50:03 +03:00
DCCONSTRUCTIONS 606cbbfae2 fix(k1): settle reopened standby after reset 2026-08-14 15:16:30 +03:00
DCCONSTRUCTIONS 0ca7316a24 wip(k1): checkpoint connection recovery rewrite
Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
2026-08-14 14:57:50 +03:00
DCCONSTRUCTIONS aff331082f fix(k1): restore canonical local connection lifecycle 2026-08-06 13:52:46 +03:00
DCCONSTRUCTIONS 52da9b75b7 fix(k1): harden BLE discovery and bridge recovery 2026-08-06 12:43:00 +03:00
DCCONSTRUCTIONS be50144ca8 docs(m4): record E47 semantic shadow evidence 2026-08-06 11:26:55 +03:00
DCCONSTRUCTIONS 81ae4425cb feat(lab): visualize semantic SLAM shadow 2026-08-06 11:26:46 +03:00
DCCONSTRUCTIONS 8eaa3ab497 feat(perception): add diagnostic semantic SLAM replay 2026-08-06 11:26:37 +03:00
DCCONSTRUCTIONS b7a51e26e6 feat(lab): add bounded local SLAM surface 2026-08-06 09:41:25 +03:00
DCCONSTRUCTIONS b8fb4ebcba fix(lab): align spatial replay controls 2026-08-06 09:14:20 +03:00
DCCONSTRUCTIONS e38f2fb1da fix(lab): stabilize recorded spatial playback 2026-08-06 08:51:10 +03:00
DCCONSTRUCTIONS 9df9f58ab8 feat(lab): add recorded realtime spatial playback 2026-08-05 23:17:48 +03:00
DCCONSTRUCTIONS aacc6dc43b fix(m4): retain compact obstacle evidence 2026-08-05 22:16:33 +03:00
DCCONSTRUCTIONS 13ed096f80 docs(m4): seal rolling map correction evidence 2026-08-05 21:12:59 +03:00
DCCONSTRUCTIONS 31313c3c96 feat(lab): separate current increment from rolling map 2026-08-05 21:12:54 +03:00
DCCONSTRUCTIONS 7aa3ce55c0 fix(perception): reconstruct rolling occupancy from K1 increments 2026-08-05 21:12:47 +03:00
DCCONSTRUCTIONS c70ad345ea fix(perception): stabilize replay body frame 2026-08-05 20:04:08 +03:00
DCCONSTRUCTIONS de19229895 feat(lab): visualize dual evidence replay 2026-08-05 19:18:14 +03:00
DCCONSTRUCTIONS b0d0bc8d7f feat(perception): add dual evidence replay threat 2026-08-05 19:18:01 +03:00
DCCONSTRUCTIONS 6b810952a4 docs(perception): record m4 temporal acceptance 2026-08-05 17:24:56 +03:00
DCCONSTRUCTIONS 998775c3af feat(perception): canonicalize temporal motion 2026-08-05 17:24:48 +03:00
DCCONSTRUCTIONS 438e0ca263 docs(perception): record m4 geometry acceptance 2026-08-05 16:56:25 +03:00
DCCONSTRUCTIONS 3ecbe3fb21 feat(perception): canonicalize metric geometry 2026-08-05 16:56:17 +03:00
DCCONSTRUCTIONS b1f9957317 docs(perception): record m4 detector acceptance 2026-08-05 16:10:40 +03:00
DCCONSTRUCTIONS 8bd632509e refactor(perception): isolate detector runtime 2026-08-05 16:05:26 +03:00
DCCONSTRUCTIONS 8e161a197f fix(perception): keep worker preflight output scalar 2026-08-05 15:59:57 +03:00
DCCONSTRUCTIONS 7756310bdf fix(perception): bind worker dependency trees 2026-08-05 15:59:00 +03:00
DCCONSTRUCTIONS b38cd51338 fix(perception): support worker powershell runtime 2026-08-05 15:56:19 +03:00
DCCONSTRUCTIONS 40794f13aa feat(perception): keep worker shadow local 2026-08-05 15:54:42 +03:00
DCCONSTRUCTIONS 720487379c feat(deploy): build worker shadow artifact 2026-08-05 15:09:34 +03:00
DCCONSTRUCTIONS c9242480c2 refactor(perception): decouple replay from lab layout 2026-08-05 14:51:33 +03:00
DCCONSTRUCTIONS 95ac235122 feat(perception): seal detector replay evidence 2026-08-05 14:36:55 +03:00
DCCONSTRUCTIONS a680debfaf feat(perception): add frozen yolox provider 2026-08-05 14:09:51 +03:00
DCCONSTRUCTIONS 029b486c67 feat(perception): add bounded reference graph 2026-08-05 13:57:27 +03:00
DCCONSTRUCTIONS ae1e41f7fe feat(perception): establish object centric contracts 2026-08-05 13:32:34 +03:00
DCCONSTRUCTIONS 8ef215e98f chore(perception): freeze milestone 4 baseline 2026-08-05 13:32:29 +03:00
DCCONSTRUCTIONS 78a3dc2162 docs(cv): define milestone 4 recorded realtime plan 2026-08-05 12:54:12 +03:00
DCCONSTRUCTIONS 1b3e0b3406 refactor(platform): freeze laboratory and telemetry boundaries 2026-08-05 12:29:06 +03:00
DCCONSTRUCTIONS 6d0abbc569 refactor(platform): bound lab canon and contour identity 2026-08-05 12:00:27 +03:00
DCCONSTRUCTIONS de12e96297 refactor(lab): canonize selected evidence reports 2026-08-05 11:49:44 +03:00
DCCONSTRUCTIONS 4c763bd8aa refactor(platform): harden LAB evidence and telemetry 2026-08-05 10:03:37 +03:00
DCCONSTRUCTIONS 67bd96868f docs(perception): update RAVNOVES00 maturation roadmap 2026-08-05 07:50:10 +03:00
DCCONSTRUCTIONS 7a72a206f1 feat(lab): present chronological perception evidence 2026-08-05 07:49:49 +03:00
DCCONSTRUCTIONS 12c8a2d74c feat(api): publish immutable perception lab evidence 2026-08-05 07:48:58 +03:00
DCCONSTRUCTIONS 3982256f08 feat(perception): add recorded replay maturation labs 2026-08-05 07:47:15 +03:00
DCCONSTRUCTIONS c1b0f6f8a3 feat(lab): separate RAVNOVES transfer from public benchmark 2026-07-31 14:35:51 +03:00
DCCONSTRUCTIONS 55b7821a5c feat(perception): run PointPillars on RAVNOVES00 2026-07-31 14:35:34 +03:00
DCCONSTRUCTIONS b44c4b3265 docs(perception): require visual LAB evidence 2026-07-31 11:47:20 +03:00
DCCONSTRUCTIONS a2cb50d1ad feat(perception): add PointPillars visual audit 2026-07-31 11:47:04 +03:00
DCCONSTRUCTIONS 8fe6184c52 docs(perception): record PointPillars transfer verdict 2026-07-31 10:48:56 +03:00
DCCONSTRUCTIONS a9a5ca5a26 feat(perception): add PointPillars transfer gate 2026-07-31 10:48:27 +03:00
DCCONSTRUCTIONS 29fee5c0ac chore(types): tighten E40 review collections 2026-07-30 23:15:16 +03:00
DCCONSTRUCTIONS dc6f9b66da feat(perception): add full camera-first shadow 2026-07-30 23:15:06 +03:00
DCCONSTRUCTIONS dee6d57133 fix(runtime): keep shared artifacts optional 2026-07-30 23:14:50 +03:00
DCCONSTRUCTIONS 8c91d40e2e fix(runtime): fail closed on offline artifact misses 2026-07-30 22:19:01 +03:00
DCCONSTRUCTIONS 81d7ad1a77 fix(lab): demote E40 to historical evaluation 2026-07-30 22:18:46 +03:00
DCCONSTRUCTIONS e4e5bf6ea1 feat(perception): restore camera-first semantic candidate 2026-07-30 21:53:34 +03:00
DCCONSTRUCTIONS fdcaf57ae7 feat(lab): add lazy E40 evidence review 2026-07-30 21:53:04 +03:00
DCCONSTRUCTIONS e9bbfb9a41 perf(data): bound lidar readers and lab session loading 2026-07-30 21:52:10 +03:00
DCCONSTRUCTIONS 7d1a70d8e0 feat(perception): qualify lossless lidar observations 2026-07-30 11:37:57 +03:00
DCCONSTRUCTIONS e56fa0c074 feat(storage): add portable session artifact gateway 2026-07-30 10:05:48 +03:00
DCCONSTRUCTIONS 2ab9e45548 fix(replay): stream sealed perception overlays 2026-07-30 00:51:14 +03:00
DCCONSTRUCTIONS 091ab2671e feat(perception): prepare blind detector review handoff 2026-07-30 00:03:41 +03:00
DCCONSTRUCTIONS cc6838ed24 fix(replay): serve admitted AI overlays without revalidation 2026-07-29 23:13:42 +03:00
DCCONSTRUCTIONS 8e4891d608 fix(system): keep worker telemetry polling live 2026-07-29 19:19:35 +03:00
DCCONSTRUCTIONS 2678295ca7 feat(map): control ghost pin movement speed 2026-07-29 18:53:00 +03:00
DCCONSTRUCTIONS 0c3855eac5 feat(map): add persisted ghost pin sandbox 2026-07-29 18:29:13 +03:00
DCCONSTRUCTIONS b91d3b913e feat(map): adopt DC Default scene 2026-07-29 17:55:44 +03:00
DCCONSTRUCTIONS 5a06194962 fix(map): complete live Cesium provider path 2026-07-29 17:25:07 +03:00
DCCONSTRUCTIONS f2a4dfe863 fix(map): align canonical map actions 2026-07-29 17:00:50 +03:00
DCCONSTRUCTIONS 8e3710b493 feat(map): add operational Cesium workspace 2026-07-29 16:45:21 +03:00
DCCONSTRUCTIONS 66f8d7a1fa feat(map): add guarded map gateway BFF 2026-07-29 16:45:11 +03:00
DCCONSTRUCTIONS d95a13059c docs(map): define Cesium integration plan 2026-07-29 16:00:07 +03:00
DCCONSTRUCTIONS 6180c3b8b7 fix(ui): restore background after playlist reset 2026-07-29 15:30:35 +03:00
DCCONSTRUCTIONS 32c5d0dda5 feat(storage): index exact content references 2026-07-29 15:21:03 +03:00
DCCONSTRUCTIONS 611853d3af fix(worker): deploy persistent runtime companions 2026-07-29 14:57:34 +03:00
DCCONSTRUCTIONS 9db39347bd feat(perception): gate sealed truth evaluation 2026-07-29 14:34:33 +03:00
DCCONSTRUCTIONS 1e1b7fe43d fix(architecture): restore generic runtime boundaries 2026-07-29 14:20:42 +03:00
DCCONSTRUCTIONS 82a44478fb feat(perception): freeze blind detector gates 2026-07-29 14:20:31 +03:00
DCCONSTRUCTIONS 8b1f109b09 feat(ui): add environment media playlists 2026-07-29 14:20:19 +03:00
DCCONSTRUCTIONS c455301764 docs(telemetry): record managed contour operations 2026-07-29 12:32:56 +03:00
DCCONSTRUCTIONS 636db089ae feat(telemetry): add bounded native pipeline lifecycle 2026-07-29 12:32:52 +03:00
DCCONSTRUCTIONS d8ccc9345a feat(system): manage compute contour network profile 2026-07-29 12:32:41 +03:00
DCCONSTRUCTIONS 1eba7c82ba fix(replay): recover failed archives and release deleted sessions 2026-07-29 01:55:23 +03:00
DCCONSTRUCTIONS 2a97cf28c0 fix(k1): stabilize repeated acquisition and live viewer recovery 2026-07-29 01:55:12 +03:00
DCCONSTRUCTIONS 37b8930527 feat(data): add read-only artifact health monitor 2026-07-29 01:54:41 +03:00
DCCONSTRUCTIONS 79eb4b46f7 feat(telemetry): expose compute pipeline stage metrics 2026-07-29 01:53:31 +03:00
DCCONSTRUCTIONS 783aa444ac feat(lab): publish E40 perception product gate 2026-07-29 01:52:56 +03:00
DCCONSTRUCTIONS bfd95c7fee chore(repo): codify local operator resource limits 2026-07-29 01:52:16 +03:00
DCCONSTRUCTIONS d729abab31 feat(perception): stabilize pre-capture methodology 2026-07-28 17:47:06 +03:00
DCCONSTRUCTIONS 1f20e0d7d9 feat(lab): publish E39 refinement result 2026-07-28 10:06:57 +03:00
DCCONSTRUCTIONS 6e5124bd9c feat(perception): record E39 R1 refinement 2026-07-28 10:06:31 +03:00
DCCONSTRUCTIONS c018f2b074 feat(lab): measure RAVNOVES00 R1 quality baseline 2026-07-28 03:42:32 +03:00
DCCONSTRUCTIONS e35f81ac34 fix(ui): align telemetry and laboratory navigation 2026-07-28 03:18:27 +03:00
DCCONSTRUCTIONS 6db5ac05ef feat(lab): publish E37 R0 acceptance result 2026-07-28 02:37:38 +03:00
DCCONSTRUCTIONS a2fcaa7b39 feat(lab): freeze RAVNOVES00 R0 acceptance contract 2026-07-28 02:24:51 +03:00
DCCONSTRUCTIONS 65a72d40a8 fix(system): preserve MQTT interval draft editing 2026-07-28 01:56:36 +03:00
DCCONSTRUCTIONS d2964a0ddc docs(ui): forbid decorative product outlines 2026-07-28 01:46:01 +03:00
DCCONSTRUCTIONS a63d2fe15b fix(ui): remove decorative outlines and restore lab spacing 2026-07-28 01:46:01 +03:00
DCCONSTRUCTIONS 2ffbb51a7e feat(system): configure telemetry polling per contour 2026-07-28 01:24:05 +03:00
DCCONSTRUCTIONS 4a65b52f6d docs(governance): codify runtime resource boundaries 2026-07-28 00:54:57 +03:00
DCCONSTRUCTIONS 1c0181297d feat(control-station): add compute contour workspace 2026-07-28 00:54:49 +03:00
DCCONSTRUCTIONS 1f4c960cc4 feat(system): add compute contour telemetry APIs 2026-07-28 00:54:18 +03:00
DCCONSTRUCTIONS b8a008b50e feat(telemetry): add portable local telemetry plane 2026-07-28 00:54:11 +03:00
DCCONSTRUCTIONS 9fc002d86f chore(worker): namespace Mission Core containers 2026-07-28 00:53:34 +03:00
DCCONSTRUCTIONS 667db00b52 feat(system): instrument worker pipeline stages 2026-07-27 21:41:40 +03:00
DCCONSTRUCTIONS 67e12a47a4 feat(system): add Worker 006 telemetry and network profile 2026-07-27 21:06:51 +03:00
DCCONSTRUCTIONS 438196cd6d docs(architecture): prioritize RAVNOVES00 maturation 2026-07-27 19:39:11 +03:00
DCCONSTRUCTIONS fa20079c9e feat(perception): audit E36 second-source eligibility 2026-07-27 18:27:40 +03:00
DCCONSTRUCTIONS b894945344 feat(perception): qualify E35 degradation recovery 2026-07-27 17:53:59 +03:00
DCCONSTRUCTIONS 621084fcd6 feat(perception): add E34 temporal occupied layer 2026-07-27 16:55:40 +03:00
DCCONSTRUCTIONS 95c6540691 feat(lab): canonize versioned report template 2026-07-27 16:05:32 +03:00
DCCONSTRUCTIONS 2c016b117d feat(lab): publish E31 through E33 results 2026-07-27 14:42:22 +03:00
DCCONSTRUCTIONS fa2d66ab0e docs(runtime): enforce canonical port 8000 2026-07-27 14:02:20 +03:00
DCCONSTRUCTIONS 9bbd5845b7 feat(perception): qualify E33 worker shadow 2026-07-27 13:43:18 +03:00
DCCONSTRUCTIONS 32257fc2b4 feat(perception): complete E32 full replay 2026-07-27 12:49:42 +03:00
DCCONSTRUCTIONS 10c306f162 feat(perception): define TrackGeometry v1 contract 2026-07-27 11:46:23 +03:00
DCCONSTRUCTIONS 091d560ac8 feat(perception): qualify E31 source binding 2026-07-27 11:33:07 +03:00
DCCONSTRUCTIONS 001d597a89 feat(lab): complete E30 evidence review gate 2026-07-27 11:00:32 +03:00
DCCONSTRUCTIONS a44d7627fd docs(lab): keep blocked gates out of the catalog 2026-07-26 18:27:16 +03:00
DCCONSTRUCTIONS 2ffaf94052 docs(lab): plan the post-E29 research cycle 2026-07-26 18:24:59 +03:00
DCCONSTRUCTIONS f50405d907 docs(lab): codify immutable laboratory runs 2026-07-26 17:56:48 +03:00
DCCONSTRUCTIONS 4db00b53f7 feat(lab): separate evidence catalogs and record methods 2026-07-26 17:56:23 +03:00
DCCONSTRUCTIONS e9ff29297b feat(lab): standardize evidence workspace template 2026-07-26 17:06:13 +03:00
DCCONSTRUCTIONS 360ec39ff3 feat(control-station): configure page-specific shell 2026-07-26 17:06:04 +03:00
DCCONSTRUCTIONS dc55ff16c9 feat(lab): verify and expose real evidence 2026-07-26 15:36:48 +03:00
DCCONSTRUCTIONS 453d760be4 feat(control-station): add configurable environment shell 2026-07-26 14:18:08 +03:00
DCCONSTRUCTIONS 42041b37cd fix(control-station): separate lab contours from recordings 2026-07-26 13:34:12 +03:00
DCCONSTRUCTIONS 05413e3f5c feat(ui): separate live data and lab surfaces 2026-07-26 12:50:10 +03:00
DCCONSTRUCTIONS 677dbdb776 feat: add camera-first lidar geometry fusion 2026-07-26 11:49:27 +03:00
DCCONSTRUCTIONS f791b63890 fix: clarify lidar source and processing result 2026-07-26 10:45:18 +03:00
DCCONSTRUCTIONS 36ea7ea3d8 feat: qualify complete K1 replay on worker 2026-07-26 10:11:56 +03:00
DCCONSTRUCTIONS a1e2cb523f feat: wire physical K1 surface shadow 2026-07-26 01:30:53 +03:00
DCCONSTRUCTIONS e6d5411bdd feat: qualify bounded K1 surface shadow 2026-07-26 01:06:04 +03:00
DCCONSTRUCTIONS 4c83e8a4e7 fix: preserve lidar review scroll 2026-07-26 00:52:50 +03:00
DCCONSTRUCTIONS 796b9306f9 feat: explain local surface residuals 2026-07-26 00:36:38 +03:00
DCCONSTRUCTIONS c6c48fbc38 feat: add local surface review triage 2026-07-26 00:11:42 +03:00
DCCONSTRUCTIONS 3449b2bc3e feat: qualify K1 local surface over time 2026-07-25 23:22:55 +03:00
DCCONSTRUCTIONS 04a658b218 feat: add passive K1 local surface replay 2026-07-25 22:47:12 +03:00
DCCONSTRUCTIONS cfe17e49bb docs: make K1 realtime map the primary lidar source 2026-07-25 22:20:13 +03:00
DCCONSTRUCTIONS d8b7ed4526 feat: publish full RELLIS qualification 2026-07-25 21:38:22 +03:00
DCCONSTRUCTIONS 21c7a5507c fix: distinguish admitted RELLIS previews from smoke 2026-07-25 21:26:02 +03:00
DCCONSTRUCTIONS 15821a1c98 fix: honor official RELLIS train sequence split 2026-07-25 21:20:13 +03:00
DCCONSTRUCTIONS 41e347d20a chore: pin observed RELLIS release archives 2026-07-25 21:16:33 +03:00
DCCONSTRUCTIONS 8446eb4492 chore: bound RELLIS qualification CLI output 2026-07-25 21:14:32 +03:00
DCCONSTRUCTIONS f2f044080f feat: qualify complete RELLIS ground dataset 2026-07-25 21:12:37 +03:00
DCCONSTRUCTIONS 054feec0d2 feat: add RELLIS dataset compatibility smoke 2026-07-25 20:45:30 +03:00
DCCONSTRUCTIONS 13c35ff446 fix(polygon): unify dataset review workflow 2026-07-25 19:54:22 +03:00
DCCONSTRUCTIONS e97573e221 fix(polygon): separate dataset sources from runs 2026-07-25 18:40:56 +03:00
DCCONSTRUCTIONS 2e5c4ba1c5 fix(polygon): focus runs on operator playback 2026-07-25 17:02:35 +03:00
DCCONSTRUCTIONS b037076506 feat(polygon): add full-frame operator review 2026-07-25 16:45:10 +03:00
DCCONSTRUCTIONS cea63f9a1e docs(lidar): record GOOSE qualification evidence 2026-07-25 16:19:07 +03:00
DCCONSTRUCTIONS d6decbe05c feat(polygon): qualify GOOSE ground providers 2026-07-25 16:02:20 +03:00
DCCONSTRUCTIONS 60ba64004b feat(lidar): qualify Patchwork++ on GOOSE 2026-07-25 15:22:28 +03:00
DCCONSTRUCTIONS 951b40c870 feat(lidar): admit and benchmark GOOSE baseline 2026-07-25 14:14:20 +03:00
DCCONSTRUCTIONS 881e97312b fix(control-station): align lidar viewer and expose polygon 2026-07-25 13:23:34 +03:00
DCCONSTRUCTIONS c01712c30d refactor(control-station): separate lidar diagnostics from datasets 2026-07-25 13:06:49 +03:00
DCCONSTRUCTIONS f57d64bf52 feat(lidar): add dataset gateway boundary 2026-07-25 11:21:22 +03:00
DCCONSTRUCTIONS 3333e9ac0f feat(lidar): add RAVNOVES field review 2026-07-25 10:26:07 +03:00
DCCONSTRUCTIONS 2dfb34ef21 feat(lidar): add point-aligned ground review 2026-07-25 09:44:38 +03:00
DCCONSTRUCTIONS 75a3e669d9 feat(lidar): add ground segmentation diagnostic benchmark 2026-07-25 02:09:27 +03:00
DCCONSTRUCTIONS e4fdd9fa20 feat(perception): add lossless lidar replay v2 2026-07-25 01:35:03 +03:00
DCCONSTRUCTIONS 3f549f91f1 feat(perception): qualify lidar evidence before models 2026-07-25 01:00:26 +03:00
DCCONSTRUCTIONS 37c24b5fa8 feat(simulation): add provider-neutral worker profile 2026-07-25 00:29:00 +03:00
DCCONSTRUCTIONS 5abd07d2bf fix(simulation): cache idle worker state 2026-07-24 23:27:51 +03:00
DCCONSTRUCTIONS bc61331604 docs(simulation): record Ackermann motion acceptance 2026-07-24 23:00:07 +03:00
DCCONSTRUCTIONS 49b0f47fea fix(simulation): tolerate cold ROS publisher startup 2026-07-24 22:51:21 +03:00
DCCONSTRUCTIONS 0fb2782f2d fix(simulation): admit cold PX4 rover startup 2026-07-24 22:47:48 +03:00
DCCONSTRUCTIONS 6ad1803aaa fix(simulation): use PX4 rover offboard control level 2026-07-24 22:40:44 +03:00
DCCONSTRUCTIONS 8ace0cd102 fix(simulation): rearm rover during active offboard run 2026-07-24 22:31:43 +03:00
DCCONSTRUCTIONS b9ce5f53a0 perf(simulation): fsync commands without replaying run history 2026-07-24 22:21:25 +03:00
DCCONSTRUCTIONS 8eecb8ed69 perf(simulation): keep rover command path within TTL 2026-07-24 22:14:56 +03:00
DCCONSTRUCTIONS a0e9597cb5 fix(simulation): source ROS setup outside nounset 2026-07-24 22:01:48 +03:00
DCCONSTRUCTIONS 97a3775acf fix(simulation): satisfy worker wrapper shell audit 2026-07-24 21:53:14 +03:00
DCCONSTRUCTIONS f63ed36913 docs(simulation): distinguish live control from archive 2026-07-24 21:51:27 +03:00
DCCONSTRUCTIONS 101707d54c feat(simulation): drive stock rover through PX4 offboard 2026-07-24 21:47:40 +03:00
DCCONSTRUCTIONS 53fd1e3bfc docs(simulation): accept worker-gated polygon ui 2026-07-24 21:29:55 +03:00
DCCONSTRUCTIONS 60e79160c9 fix(simulation): keep rover orbit above ground 2026-07-24 21:16:38 +03:00
DCCONSTRUCTIONS 17a0e7a7f5 fix(simulation): hide launcher panel for polygon 2026-07-24 21:13:17 +03:00
DCCONSTRUCTIONS ee7d1dd26c fix(simulation): open polygon as direct workspace 2026-07-24 21:10:37 +03:00
DCCONSTRUCTIONS 6cc40282e0 feat(simulation): add worker-gated 3d polygon workspace 2026-07-24 21:03:41 +03:00
DCCONSTRUCTIONS 1995dfdf68 docs(simulation): accept S1C live worker 2026-07-24 20:47:08 +03:00
DCCONSTRUCTIONS b7ccca3481 feat(simulation): add Polygon live worker gateway 2026-07-24 20:33:08 +03:00
DCCONSTRUCTIONS 359f0d1d39 docs: define distributed Mission Core topology 2026-07-24 19:40:59 +03:00
DCCONSTRUCTIONS b790f29d59 feat: add Polygon UI-0 run view 2026-07-24 19:02:10 +03:00
DCCONSTRUCTIONS a78c8c83f5 docs(simulation): accept S1B real provider lifecycle 2026-07-24 18:39:59 +03:00
DCCONSTRUCTIONS 6cb14954d4 fix(simulation): stabilize PX4 readiness supervision 2026-07-24 18:34:20 +03:00
DCCONSTRUCTIONS b4e3b3b735 fix(simulation): inspect namespace through netlink 2026-07-24 18:29:52 +03:00
DCCONSTRUCTIONS 01ec12263a feat(simulation): qualify real S1B provider lifecycle 2026-07-24 18:26:43 +03:00
DCCONSTRUCTIONS e1809ac9e1 docs(simulation): record S1 target bootstrap and UI gates 2026-07-24 18:04:11 +03:00
DCCONSTRUCTIONS 630d1ae819 feat(simulation): add S1 worker lifecycle ownership 2026-07-24 17:52:17 +03:00
DCCONSTRUCTIONS 12fba7126d fix(simulation): persist command authority provenance 2026-07-24 17:39:26 +03:00
DCCONSTRUCTIONS fc8c81dbc4 feat(simulation): add S1 qualification run repository 2026-07-24 17:37:55 +03:00
DCCONSTRUCTIONS a19053dfd4 docs(simulation): accept Polygon SIM S0 2026-07-24 17:24:26 +03:00
DCCONSTRUCTIONS 2548bb5740 fix(simulation): accept ROS multi-document samples 2026-07-24 17:21:04 +03:00
DCCONSTRUCTIONS fc5cbdff8a fix(simulation): scope worker git ownership checks 2026-07-24 17:20:10 +03:00
DCCONSTRUCTIONS c9b1009259 feat(simulation): qualify S0 time and resource gates 2026-07-24 17:19:28 +03:00
DCCONSTRUCTIONS 6290fcb2ce feat(simulation): qualify D-only stock rover smoke 2026-07-24 16:51:39 +03:00
DCCONSTRUCTIONS 81761067e9 feat(simulation): establish polygon S0 qualification 2026-07-24 15:29:08 +03:00
DCCONSTRUCTIONS 7fba39a629 feat(perception): add camera ego-motion evidence 2026-07-24 13:37:39 +03:00
DCCONSTRUCTIONS 230cba4b21 feat(perception): add persistent lidar motion evidence 2026-07-24 12:49:11 +03:00
DCCONSTRUCTIONS e6dd8c2cf1 feat(perception): qualify world-frame motion tracking 2026-07-24 11:17:10 +03:00
DCCONSTRUCTIONS 23181c867b feat(perception): qualify inline temporal stability 2026-07-24 09:10:07 +03:00
DCCONSTRUCTIONS cfc7b062da feat(perception): qualify bounded temporal stability 2026-07-23 23:30:50 +03:00
DCCONSTRUCTIONS 5a29a536a2 fix(observation): admit bounded LAB chunk coverage 2026-07-23 22:34:07 +03:00
DCCONSTRUCTIONS 082b7057da fix(perception): bound LAB replay to AI window 2026-07-23 21:15:53 +03:00
DCCONSTRUCTIONS 10019a454a feat(perception): publish immutable lab session instances 2026-07-23 20:13:40 +03:00
DCCONSTRUCTIONS f1f8e21b78 feat(perception): qualify 1x realtime replay envelope 2026-07-23 19:01:12 +03:00
DCCONSTRUCTIONS ae2ce055c8 fix(viewer): preserve follow camera across layer toggles 2026-07-23 17:14:32 +03:00
DCCONSTRUCTIONS a7babf60db fix(viewer): keep recorded follow locked to rig 2026-07-23 15:11:38 +03:00
DCCONSTRUCTIONS 90450ae320 Preserve recorded camera orbit when toggling follow 2026-07-23 13:57:36 +03:00
DCCONSTRUCTIONS ecd95a22f0 feat(viewer): add recorded point colors and trajectory follow 2026-07-23 13:33:08 +03:00
DCCONSTRUCTIONS 9f712bf353 fix(viewer): gate recorded perception readiness 2026-07-23 12:27:48 +03:00
DCCONSTRUCTIONS ee15160862 fix(viewer): make recorded layers deterministic 2026-07-23 11:42:19 +03:00
DCCONSTRUCTIONS 3a64f54dea fix(viewer): preserve operator camera across AI layers 2026-07-23 10:51:18 +03:00
DCCONSTRUCTIONS a0706fd5d8 feat(perception): add ground-aware cuboid refusion 2026-07-23 08:24:37 +03:00
DCCONSTRUCTIONS b53d6d5a45 feat(perception): integrate calibrated operator pipeline
Add calibrated K1 projection, recorded and near-live perception qualification, unified Rerun operator layers, bounded replay admission, audited viewer controls, worker experiments, and lab evidence.
2026-07-23 00:23:28 +03:00
DCCONSTRUCTIONS ada2a55ee6 docs(perception): record Rerun projection acceptance 2026-07-19 17:39:28 +03:00
DCCONSTRUCTIONS 2b53168149 feat(perception): project recorded results into Rerun 2026-07-19 17:39:13 +03:00
DCCONSTRUCTIONS 31fc4f6567 docs(perception): record external worker acceptance 2026-07-19 16:46:39 +03:00
DCCONSTRUCTIONS 648d5bced4 feat(compute): add bounded camera job contract 2026-07-19 16:46:32 +03:00
DCCONSTRUCTIONS 75d2e5da4b docs(k1): record connection mode acceptance 2026-07-19 09:52:33 +03:00
DCCONSTRUCTIONS 57b2208cae feat(k1): add local connection matrix 2026-07-19 09:52:22 +03:00
DCCONSTRUCTIONS fecb5885d0 docs(milestone): record K1 control and archive acceptance 2026-07-19 01:07:33 +03:00
DCCONSTRUCTIONS 71c85e9894 feat(archive): complete recorded session lifecycle 2026-07-19 01:07:21 +03:00
DCCONSTRUCTIONS ffffee1879 feat(k1): complete canonical control lifecycle 2026-07-19 01:07:02 +03:00
DCCONSTRUCTIONS d7a2c22faf fix(k1): simplify response-gated launch UX 2026-07-18 18:17:16 +03:00
DCCONSTRUCTIONS 7b7b5d6cad feat(k1): wire canonical control session to UI 2026-07-18 17:53:55 +03:00
DCCONSTRUCTIONS 36f0c93d2a fix(k1): gate control dialogue on live state 2026-07-18 17:19:21 +03:00
DCCONSTRUCTIONS a29b38a0d7 fix(k1): record redacted acceptance failures 2026-07-18 16:11:29 +03:00
DCCONSTRUCTIONS eddc09008e feat(k1): add physical acceptance transport 2026-07-18 15:17:46 +03:00
DCCONSTRUCTIONS a4accf0fb6 feat(k1): wire dormant control lease 2026-07-18 14:37:02 +03:00
DCCONSTRUCTIONS ea811ff370 feat(k1): add offline control execution gate 2026-07-18 14:14:30 +03:00
DCCONSTRUCTIONS d7749208a7 feat(k1): recover exact application bootstrap 2026-07-18 13:54:25 +03:00
DCCONSTRUCTIONS af9319a33b feat(k1): add device-bound shadow control gate 2026-07-18 12:09:23 +03:00
DCCONSTRUCTIONS 0ac6424c46 fix(ui): scope spatial tools to scene 2026-07-18 11:31:44 +03:00
DCCONSTRUCTIONS 574a494759 fix(archive): preserve prepared sessions across restarts 2026-07-18 11:08:27 +03:00
DCCONSTRUCTIONS aa3680948f feat(k1): complete primary acquisition lifecycle 2026-07-17 23:03:59 +03:00
DCCONSTRUCTIONS 9d51080d2e feat(plugins): add runtime handshake boundary 2026-07-17 19:50:01 +03:00
DCCONSTRUCTIONS 24a47318f2 feat(plugins): isolate device integrations 2026-07-17 19:29:32 +03:00
DCCONSTRUCTIONS f9ffb7bd1c docs(observation): document durable sessions and viewer lifecycle 2026-07-17 17:55:39 +03:00
DCCONSTRUCTIONS e94c64eebd feat(control-station): add atomic recorded-session playback 2026-07-17 17:51:24 +03:00
DCCONSTRUCTIONS 007ff9fff2 feat(k1): integrate durable evidence with acquisition lifecycle 2026-07-17 17:51:05 +03:00
DCCONSTRUCTIONS 656f0c524d feat(sessions): add durable observation archive and replay API 2026-07-17 17:50:54 +03:00
DCCONSTRUCTIONS aa2df560b7 feat(ui): refine live observation workspace 2026-07-17 01:04:48 +03:00
DCCONSTRUCTIONS 2bda1986bd feat(k1): add live cameras and reliable spatial following 2026-07-17 00:26:03 +03:00
DCCONSTRUCTIONS a281faf923 feat(observation): add dynamic camera sources 2026-07-16 20:56:41 +03:00
DCCONSTRUCTIONS 05bdab24a5 docs: record Mission Core K1 architecture milestone 2026-07-16 19:44:16 +03:00
DCCONSTRUCTIONS e6f7648b84 feat(device-plugins): add profiled K1 lifecycle and canonical data plane 2026-07-16 19:44:06 +03:00
DCCONSTRUCTIONS 19ab973110 feat(lab): add isolated iPhone capture evidence 2026-07-16 19:43:41 +03:00
DCCONSTRUCTIONS 63dd8c8790 feat(plugin-sdk): add executable v0alpha2 contracts 2026-07-16 19:43:26 +03:00
DCCONSTRUCTIONS 27bf7527df feat: introduce device plugin runtime boundary 2026-07-16 13:49:10 +03:00
DCCONSTRUCTIONS 9225227421 chore: rename repository to NODEDC MISSION CORE 2026-07-16 12:10:05 +03:00
DCCONSTRUCTIONS 8459bb03fb fix: align control station with current checker API 2026-07-16 11:56:45 +03:00
1662 changed files with 642044 additions and 7525 deletions
@@ -0,0 +1,260 @@
---
name: mission-core-product-ui
description: Design and enforce Mission Core product surfaces, information architecture, product UI, and laboratory presentation. Use for every new or relocated workspace, product root, navigation category, panel, viewer, Control Station surface, LAB catalog/summary, visualization control, fullscreen behavior, product-facing status, or UI refactor in NODEDC_MISSION_CORE, including work that could introduce a new component, page pattern, or task-specific composition.
---
# Mission Core product UI
## Required context
Before changing UI:
1. Read the repository `AGENTS.md`.
2. Read `docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md`.
3. Read `docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md`.
4. For a new, relocated, or materially changed non-LAB surface, read
`docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`.
5. For LAB work, also read `docs/15_LABORATORY_RUN_CANON.md`.
6. Read the sibling Design Guideline:
- `registry/registry.json`
- `registry/components.json`
- `registry/icons.json`
- `docs/COMPONENTS.md`
- `docs/GOVERNANCE.md`
- the documentation for the affected page pattern or component.
Do not start implementation before resolving the required controls and icons in
the registries.
## Canonical local endpoint
- Keep the integrated Mission Core Control Station and API exclusively on
`http://127.0.0.1:8000`.
- Treat the canonical `8000` process as durable operator state, not as a
disposable QA process. Finishing a test, build, browser check, Codex turn, or
implementation increment never authorizes stopping it.
- At the start of every Control Station task, inspect `8000`. If it is not
serving Mission Core, start the canonical integrated service before UI work.
At handoff, verify it again and leave it running.
- A rebuild may replace the process on `8000`, but the replacement must be the
same canonical service and must be listening successfully before handoff.
Never leave the operator with a stopped Control Station after a restart.
- Never start a second integrated backend on an alternate port to bypass a
stale or occupied `8000`. In particular, TCP `8765` is a legacy Foxglove
regression port and is not an operator path.
- Before browser QA or product handoff, inspect listeners on `8000` and `8765`.
If the Mission Core process on `8000` is stale, restart that exact canonical
service instead of launching another instance.
- Use `5173` only for an explicitly requested Vite development session and
`4173` only for an explicitly requested production preview. Both must proxy
the API to `8000`; neither changes the canonical integrated endpoint.
- Finish with exactly one integrated Mission Core backend on `8000` and no
Mission Core backend on `8765`. Open or hand off `8000`, never an alternate
backend URL.
## Local workstation resource gate
- The local operator machine is a 14-inch 2023 MacBook Pro with only 18 GB of
physical memory. Its memory, swap, CPU, GPU, and thermal headroom are part of
the product-test safety boundary.
- Run memory-intensive work sequentially. Never overlap a full frontend test
pass, production build, Docker/Compose build or startup, synthetic replay,
and browser visual QA.
- Before starting a memory-intensive operation, inspect host memory pressure and
the active Docker/process workload. If memory pressure is elevated or swap is
growing, stop temporary work and recover headroom before continuing.
- Prefer focused tests and an existing valid build. Run the full suite and
production build only when they are required acceptance gates, one at a time.
- Do not start Docker Desktop, Compose stacks, duplicate application servers,
duplicate browser automation sessions, or load/stress tests for convenience.
Bounded load work belongs on Worker 006, not on the Mac.
- After each test, build, replay, or visual-QA stage, stop temporary watchers,
publishers, viewers, workers, and containers before starting the next heavy
stage. Preserve only the canonical integrated service on `8000` and other
explicitly required durable operator services.
- Do not change Docker Desktop CPU or memory limits without explicit product
owner approval. A configured Docker VM ceiling is not safe available host
memory.
## Component gate
- Reuse package exports from `@nodedc/ui-react`, `@nodedc/ui-core`,
`@nodedc/tokens`, and `@nodedc/page-patterns`.
- Do not create a local substitute for a button, icon button, segmented switch,
select, dropdown, glass surface, status, window, focus state, hover state, or
page pattern.
- Treat an absent generic visual entity as a product decision. Stop, describe
the user need and proposed generic API, and obtain explicit approval.
- After approval, implement the generic entity in the Design Guideline first,
including registry, documentation, interaction states, catalog example, and
validation. Consume it from Mission Core only afterward.
- Allow Mission Core domain renderers, data adapters, and domain layouts. Build
their controls from canonical exports.
Explicit approval for one entity does not authorize unrelated visual entities.
## Product surface design
Keep product grammar stable without forcing one layout:
- Reuse the canonical shell, controls, states, icons, focus, motion, and
responsive behavior.
- Never add decorative perimeter borders, outlines, inset rings, or persistent
edge strokes to Mission Core cards, panels, topology nodes, telemetry blocks,
task stages, or LAB content surfaces. Separate product hierarchy with
approved surface tones, spacing, typography, and elevation.
- Preserve canonical keyboard focus indicators and accessibility outlines from
the Design Guideline. The no-outline rule applies to decoration, never to
focus visibility, data-visualization grids, or a necessary structural
divider.
- Change composition when the operator job, primary entity, lifecycle, or
action model changes materially.
- Do not treat the LAB template as the default for non-LAB work.
- Choose the smallest complete surface: view mode, inspector/panel, floating
window, existing section, dedicated workspace, product root, plugin slot, or
LAB result.
Before adding or relocating a workspace or changing primary navigation:
1. State the operator job, trigger/frequency, primary entity/lifecycle,
evidence, authority, actions, and complete state grammar.
2. Compare at least two credible placements or compositions.
3. Resolve the Design Guideline primitives and page patterns.
4. Classify the novelty:
- domain content in an admitted composition;
- new Mission Core-specific composition;
- new shared Design Guideline entity/pattern;
- new product root or platform meaning.
5. Present the selected placement and rejected alternative with UX reasoning.
6. Obtain product-owner agreement for a new/relocated workspace, primary
navigation change, new root, or shared visual pattern.
7. Implement only after the decision; do not add placeholder product routes or
temporary navigation.
Valid task-specific compositions include scene-first, map-first,
timeline-first, list/detail queue, table/catalog, form/editor, graph/topology,
dashboard, and immutable LAB review.
## Application architecture
- Preserve `core → components/renderers → workspaces → composition/App`.
- Core owns contracts, adapters, state machines, and domain hooks. It must not
import visual adapters, workspaces, or App.
- Reusable components may consume Core and canonical UI exports. They must not
import workspaces or own product navigation.
- Add every new domain or LAB run as a bounded feature module. Do not append its
API adapter, renderer, selectors, or styles to `App.tsx`,
`Workspaces.tsx`, or `styles/workspaces.css`.
- Keep LAB composition under `workspaces/laboratory` and pass generic host
viewers through typed contracts.
- Treat `productModel.ts` as the application registry. Do not duplicate root,
workspace, icon, or capability lists in the shell.
- Do not introduce a runtime ontology merely to organize code. Use the typed
registry, executable contracts, and experimental vocabulary until the
multi-consumer admission gate in the architecture canon is met.
- Never raise an architecture line-count ratchet to fit a new feature. Split
the feature and retain the boundary test.
## Laboratory product contract
Use the executable template
`missioncore.laboratory-report/v1`:
`selectors → compact summary → evidence → result → optional reusable details`
For every new bounded `workspaces/laboratory/ENNResult.tsx`:
1. Pass typed values into `LaboratorySummary`; always supply `question`,
`approach`, `principalResult`, and `limitation`.
2. Put visual evidence in `LaboratoryEvidence`.
3. Pass result metrics and `proved`, `notProved`, and `decision` into
`LaboratoryResultSummary`.
4. Do not render the `laboratory-summary`, `laboratory-result-summary`,
`laboratory-result-metrics`, or `laboratory-result-conclusion` classes in the
LAB module.
5. Do not change slot order or shared report geometry for one run.
Treat a missing field as missing product evidence, not as permission to omit
the section. An admitted diagnostic viewer may own the result interaction only
when the existing canon explicitly identifies that viewer as the result
instrument.
Changing the v1 anatomy requires product-owner agreement, a new template
version, migration of every consumer, updated canon, and an updated
architecture test. Never drift v1 incrementally from a single LAB.
The compact summary must cover:
- decision question and purpose;
- immutable source and tested bounds;
- pipeline, execution class, models, algorithms, tools, and worker/runtime;
- experimental feature or configuration;
- principal result and limitation;
- retained authority.
Keep manifests, long hashes, implementation files, validation logs, regressions,
rejected approaches, and next-stage planning in the Mission Core Ops report.
Do not expose roadmap steps, next-gate checklists, internal reason taxonomies,
debug controls, placeholder blocks, or implementation scaffolding in product
UI.
## Evidence viewers
- Use one reusable viewer frame for related representations.
- Provide canonical expand/restore through `IconButton` and `Icon`.
- Provide mode switching through `SegmentedControl`.
- Preserve selected evidence and mode across resize.
- Use 3D for spatial shape, range, height, topology, support, and occupied
volume.
- Use 2D for camera-plane reprojection, bbox/mask agreement, calibration,
field-of-view, occlusion, and pixel correspondence.
- When both are useful, keep them as modes of the same viewer and share source
indices and selected case.
- Keep renderer colors semantic and token-derived. Never use them to invent
control states or persistent colored outlines.
## Ops report boundary
When the task includes reporting a completed LAB or architecture milestone, use
the direct `nodedc-ops-agent` tools and write titled structured blocks in this
order:
1. Objective and architecture stage.
2. Decision question and hypothesis.
3. Immutable source evidence and bounds.
4. Method, preprocessing, models, algorithms, tools, and identities.
5. Worker/runtime topology and resource policy.
6. Experimental implementation.
7. Validation and reproduced evidence.
8. Results.
9. Regressions, rejected approaches, and limitations.
10. Decision.
11. Next stage and forbidden authority.
12. Acceptance checker.
Do not turn the product UI into a duplicate Ops report.
## Validation
Before handoff:
1. Search the changed product UI for raw local controls, hard-coded product
colors, decorative perimeter borders/outlines, temporary copy,
gate/checklist UI, and per-LAB page branches. Justify every remaining
`border`, `outline`, or inset `box-shadow` as a canonical focus state,
data-visualization grid, or necessary structural divider.
2. Verify every changed or new bounded `ENNResult.tsx` uses
`LaboratorySummary`, `LaboratoryEvidence`, and `LaboratoryResultSummary`
without raw canonical report markup.
3. For a new surface, verify the product-surface brief names the user job,
selected placement, rejected alternative, state grammar, and real acceptance
evidence.
4. Under the local workstation resource gate, run
`test/applicationArchitecture.test.mjs`, then the full frontend typecheck,
unit tests, and production build sequentially. Never overlap these jobs.
5. After the build/test processes have exited and memory headroom is confirmed,
use the in-app browser to verify normal and expanded modes, every new view
mode, keyboard Escape, and the canonical control states.
6. Verify the default composition answers the primary operator question.
7. Leave a real evidence case open for product-owner review.
@@ -0,0 +1,4 @@
interface:
display_name: "Mission Core Product UI"
short_description: "Mission Core surfaces and LAB product architecture"
default_prompt: "Use $mission-core-product-ui to design or review a Mission Core product surface and its placement."
+1 -1
View File
@@ -9,4 +9,4 @@
*.xbin binary
*.las binary
*.lcc binary
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
+5
View File
@@ -12,6 +12,7 @@ __pycache__/
.coverage
htmlcov/
dist/
apps/control-station/dist-shadow/
build/
*.egg-info/
@@ -21,10 +22,14 @@ build/
!.env.example
config/local.toml
private/
.runtime/
# Real laboratory captures and decoded artifacts are sensitive and large.
captures/
sessions/
# The host runtime package shares the domain name but is source, not evidence.
!src/k1link/sessions/
!src/k1link/sessions/**/*.py
artifacts/raw/
artifacts/decoded/
*.pcap
+131 -3
View File
@@ -1,8 +1,9 @@
# Repository operating rules
This repository investigates an owner-controlled XGRIDS/LixelKity K1 as a
black-box sensor. Preserve the device, the Mac, the working LAN, and raw
evidence.
NODEDC MISSION CORE is a vendor-neutral mission-control monorepo. Its first
real device plugin investigates an owner-controlled XGRIDS/LixelKity K1 as a
black-box sensor. Preserve the device, the host, the working LAN, raw evidence,
and the boundary between Mission Core and vendor-specific integration code.
## Non-negotiable safety boundaries
@@ -23,6 +24,33 @@ evidence.
- Do not install or alter Homebrew/system components unless the user explicitly
authorizes that concrete change.
## Local operator resource envelope
- The local operator machine is a 14-inch 2023 MacBook Pro with only 18 GB of
physical memory. Treat local memory and swap as a hard shared operational
limit, not as disposable build capacity.
- Run frontend tests, production builds, Docker builds, runtime startup, and
browser QA sequentially. Do not launch parallel heavy local jobs.
- Do not start Docker Desktop, a Compose stack, a second browser automation
session, or a full test/build pass merely for convenience. First prove that
the operation is necessary, prefer the narrowest focused check, and keep no
more than one memory-intensive validation job active at a time.
- Do not run load or stress tests on the Mac. Run bounded synthetic load only on
Worker 006, and remove every temporary process after the measurement.
- Use only the canonical Mission Core endpoint on port `8000`; do not start
duplicate application servers to work around stale state.
- Before a memory-intensive local operation, inspect current memory pressure and
active Docker workload. If memory pressure is elevated or swap is growing,
stop and remove temporary jobs before continuing. Prefer focused tests and
existing build artifacts when they are sufficient for acceptance.
- After every local test, build, replay, browser-QA, or Docker operation, stop
temporary workers, watchers, replay publishers, duplicate viewers, and
containers that are not durable operator state. Keep only the canonical
Mission Core service on `8000` and explicitly required operator services.
- Docker Desktop's configured VM ceiling is not evidence that the host can
safely supply that memory. Do not change Docker Desktop CPU or memory limits
without explicit owner approval.
## Evidence rules
- Reference inputs under `docs/reference/` are immutable; write corrections in
@@ -38,3 +66,103 @@ evidence.
Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy
decoders before BLE/Wi-Fi/data-session evidence exists.
## Docker runtime governance
- Every new or migrated durable NODE.DC-owned Docker container, Compose project,
network, named volume, and first-party image uses the lowercase `ndc-`
namespace. The prefix is mandatory for anything that may leave a laboratory
workstation; a legacy project/network name may survive only behind a bounded,
documented migration barrier.
- Do not retag or rename unrelated third-party runtime owned by another product.
Express NODE.DC ownership through the managed object name and
`com.nodedc.product`, `com.nodedc.stack`, `com.nodedc.role`, and
`com.nodedc.managed-by` labels.
- One operator-managed contour may be one Compose project while still using
separate single-purpose containers. Do not merge a broker, normalizer,
database, or application process into a multi-process container merely to
make Docker Desktop show one row.
- Treat Compose and worker orchestration declarations as the source of truth.
A live `docker rename` is allowed only as a bounded migration with exact
predecessor IDs, a persisted declaration change, rollback, and post-change
health acceptance.
- Keep bounded legacy-name fallback only for an explicit migration window.
New defaults, UI probes, runbooks, and durable runtime names must use `ndc-`.
## Product UI governance
- For every Control Station, LAB, viewer, or product-presentation change, use
`.codex/skills/mission-core-product-ui/SKILL.md` and follow
`docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md` and
`docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md`.
- The integrated Control Station and API have one canonical local endpoint:
`http://127.0.0.1:8000`. Never start a second Mission Core backend on another
port to bypass a stale process. TCP `8765` is legacy Foxglove regression
only, not an operator path. Restart the canonical `8000` process and finish
with no Mission Core backend listening on `8765`.
- The canonical `8000` process is durable operator state. A completed test,
build, browser QA pass, Codex turn, or implementation increment never
authorizes stopping it. Every Control Station task starts by checking and, if
necessary, starting the integrated service; every handoff verifies and leaves
that exact service running on `8000`. A rebuild may replace it only when the
replacement is confirmed listening before handoff.
- `NODEDC_DESIGN_GUIDELINE` is the only visual-design source of truth. Before
editing UI, read its `registry/registry.json`, `registry/components.json`,
`registry/icons.json`, and the relevant component documentation.
- Reuse `@nodedc/ui-react`, `@nodedc/ui-core`, tokens, icons, and page patterns.
Do not create an application-local visual control, interaction state,
geometry, color language, or copy of a design-system component.
- If the required visual entity is absent from the Design Guideline, stop and
obtain explicit product-owner approval. After approval, add it to the Design
Guideline first with registry, documentation, states, and validation; only
then consume it here.
- Domain renderers may remain Mission Core code when they visualize Mission
Core data. Their controls and containing surfaces must still be composed from
canonical Design Guideline exports.
- Product UI must not expose implementation steps, roadmap gates, internal
reason taxonomies, debug controls, placeholder status blocks, or temporary
experiment scaffolding. Keep these in Ops, engineering reports, or developer
tooling.
- Extend one reusable application pattern instead of adding per-LAB layouts.
A new LAB supplies data and renderer configuration; it does not invent a new
page hierarchy or visual language.
- Do not generalize the LAB hierarchy into a universal application template.
For a new non-LAB interface, follow
`docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`: identify the operator job,
compare placement/composition alternatives, classify the novelty, and obtain
product-owner agreement before adding a workspace, changing primary
navigation, or introducing a new product root.
- Preserve one product grammar while allowing task-specific composition. A
scene, map, timeline, queue, editor, topology, dashboard, and LAB review may
have different layouts when their entity, lifecycle, or action model differs.
- Preserve the internal dependency direction:
`core → components/renderers → workspaces → composition/App`. Core never
imports workspaces or visual adapters; reusable components never import
workspaces. New domains and LAB runs get their own feature module instead of
growing `App.tsx`, `Workspaces.tsx`, or generic CSS buckets.
- Treat `productModel.ts`, executable versioned contracts, and the experimental
vocabulary as the current local semantic sources. Do not add a parallel
runtime ontology unless the admission conditions in
`docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md` are met.
## Laboratory presentation and reporting
- Use one fixed laboratory presentation contract: selectors, one compact
canonical summary, admitted evidence viewer(s), result, and optional reusable
technical details.
- Keep `missioncore.laboratory-report/v1` executable: bounded `ENNResult.tsx`
modules supply all four `LaboratorySummary` brief fields and use
`LaboratoryResultSummary` for metrics plus proved/not-proved/decision. They
never own canonical summary/result markup. Anatomy changes require a new
version and product-owner agreement.
- The compact summary must explain the decision question, immutable source,
tested method/models/algorithms, experimental mode, principal result,
limitations, and retained authority. It is not the engineering report.
- The complete engineering report belongs in the Mission Core Ops card as
titled structured blocks: objective and architecture stage, source evidence,
method/models/algorithms, worker/runtime, implementation, validation,
results, regressions and limitations, decision, next stage, and acceptance
checker.
- Spatial evidence uses 3D by default. Image-space reprojection may be offered
as a 2D diagnostic mode inside the same reusable viewer. Primary evidence
viewers must provide the canonical expand/restore action.
+486 -55
View File
@@ -1,16 +1,52 @@
# NDC XGRIDS K1 Connector
# NODEDC MISSION CORE
Pre-production research project for connecting an owner-controlled
XGRIDS/LixelKity K1 to a Mac without LixelGO, firmware changes, device opening,
or speculative writes.
NODEDC MISSION CORE is the vendor-neutral control, observation, mission-planning,
recording, and integration platform for NODEDC autonomous systems. This
repository is the canonical early-stage monorepo: it contains the Mission Core
Control Station, the current local control plane, shared contracts as they are
extracted, and device plugins.
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, 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 first proven hardware vertical is the XGRIDS/LixelKity K1 plugin. On firmware
3.0.2 the host provisions the scanner onto an existing LAN without LixelGO,
connects to its MQTT broker, persists each raw frame before preview work, decodes
point cloud and pose, and renders the real cloud plus trajectory through an
embedded self-hosted Rerun Web Viewer. Native MQTT persistence uses bounded
group commit (at most 0.5 seconds, 4 MiB or 32 messages), and camera archives
commit complete fMP4 segments before their index rows. These are explicit
crash-RPO bounds, not a zero-loss or disk-replication claim. The former
Foxglove bridge remains only as a legacy regression module.
The backend vendor implementation is physically isolated below
`src/k1link/device_plugins/xgrids_k1/`; its reviewed manifest and compatibility
profile remain below `plugins/xgrids-k1/`. Mission Core host code discovers
archive sources, recovery hooks and recording exporters only through the plugin
runtime contribution. The verified wire protocol and raw evidence format are
unchanged.
The K1 acquisition now also requires an operator project name. Frontend and
backend normalize it with NFKC plus surrounding-whitespace trimming, reject
control/surrogate characters and values above 96 Unicode characters, and
preserve it as local session/catalog display metadata. It is never used as a
filesystem path. The physically accepted interactive control path carries the
same validated value in its single canonical START; there is no separate
project-name write.
Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session,
operation, runtime-action, stream, evidence and compatibility contracts. Every
backend runtime must now pass a versioned descriptor/handshake against its
manifest before actions are admitted. Every action then crosses immutable SDK
`RuntimeActionInvocation` and `RuntimeActionResult` validation through a
replaceable runtime transport seam. The meanings remain a local experimental
vocabulary rather than a mutation of NODE.DC Platform Ontology. The current
transport is deliberately in process: it uses an explicitly injected K1
normalizer to produce transport-neutral local consumer views; portable SDK
stream envelopes, process isolation, durable operations, multi-device routing
and the remote Edge split remain later gates.
The application-control bootstrap reads and correlates live `DeviceInfo` before
START. The exact profile requires model `LixelKity K1`, observed platform type
`A4`, activated state, firmware `3.0.2`, and direct-LAN topology. The UI selects
that profile but does not ask the operator to attest firmware manually.
The repository now contains one narrowly gated state-changing command:
`ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning
@@ -18,12 +54,21 @@ profile and requires explicit `--confirm-write`; the Wi-Fi password is collected
through a hidden local macOS dialog. MQTT capture and decoding are read-only.
Nothing changes router settings, firmware or global Python packages.
## Available stand
## Verified XGRIDS K1 stand
- one XGRIDS/LixelKity K1;
- one Apple Silicon MacBook running macOS;
- one ordinary TP-Link Deco/mesh network used by other devices;
- no LixelGO, phone, Linux host, dedicated AP, OpenWrt, or vendor SDK.
- the proven baseline used no LixelGO, phone, Linux host, dedicated AP,
OpenWrt, or vendor SDK.
An owner-controlled iPhone with LixelGO is now available for a separate,
evidence-only observation stage. It does not invalidate the no-phone baseline
and is not a runtime dependency. The decision and gated runbook are
[`ADR 0005`](docs/adr/0005-owner-controlled-lixelgo-iphone-observation.md) and
[`docs/08_LIXELGO_IPHONE_OBSERVATION.md`](docs/08_LIXELGO_IPHONE_OBSERVATION.md).
The Mac capture environment is isolated under
`plugins/xgrids-k1/lab/iphone-capture/` and does not require full Xcode.
The ordinary router is sufficient for the first gates. We first observe the
existing LAN without changing it. A Guest/IoT SSID is optional and may be
@@ -39,10 +84,11 @@ The project has three independent gates:
3. Without LixelGO, K1 can be associated with Wi-Fi and a proprietary data
session can be opened.
All three gates are now proven on the tested unit. The external stream is plain
All three gates are now proven on the tested unit. The spatial stream is plain
MQTT 3.1.1 on TCP 1883. Firmware-3 `lio_pcl` is protobuf wrapped in a raw LZ4
block, and `lio_pose` is an uncompressed protobuf. Raw panoramic camera access is
still unproven and is not implied by point-cloud success.
block, and `lio_pose` is an uncompressed protobuf. Owner-operated LixelGO capture
also proved separate left/right RTSP/H.264 camera previews on TCP 8554. This is a
compressed preview contract, not proof of full-resolution raw camera access.
## Local environment
@@ -51,26 +97,281 @@ This does not install Python packages globally and does not modify neighboring
repositories.
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NDC_xgrids-k1-connector
uv sync --group dev
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
uv sync --frozen --group dev
uv run k1link doctor
uv run pytest
```
## NODE.DC Control Station and visualization adapters
## External perception worker
The browser application is now a universal NODE.DC Control Station rather than
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
sections — Center, Fleet, Observation, Missions, Data and System — while the K1
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter.
Mission Core can now package one sealed camera epoch as an immutable,
content-addressed `missioncore.compute-job/v1` without changing the source
evidence. The first recorded TEST007 job was digest-verified and decoded on a
separate Windows RTX 4090 worker, executed through pinned Triton/YOLOX-S and
returned a content-addressed `missioncore.compute-result/v1` containing 56
session-timestamped generic COCO detections. The worker never connects to K1 and
does not own the raw archive.
Mission Core now validates and projects that optional result into the same saved
Rerun recording. Open TEST007 and use **Распознавание** to see the recorded
left-camera frames and boxes; **Облако точек** returns to 3D. This remains a
recorded camera-only acceptance, not a navigation stack: large-epoch
performance, direct worker transport and bounded live fan-out remain open. See
[ADR 0014](docs/adr/0014-bounded-external-perception-worker.md)
and the [external worker contract](docs/10_EXTERNAL_PERCEPTION_WORKER.md).
## LiDAR Dataset Gateway
Mission Core now distinguishes a single native LiDAR scan, a normalized
sensor-frame scan and a pose-registered rolling local map. The first Dataset
Gateway adapter reads GOOSE/SemanticKITTI XYZI plus point-wise semantic and
instance labels without silently accumulating or transforming them. Current
K1 `lio_pcl` remains a post-LIO vendor-map increment and is not promoted to a
raw scan. Large dataset bytes are admitted only on the Windows/WSL worker under
`D:\NDC_MISSIONCORE\datasets`; no archive is downloaded automatically.
That representation boundary does not reject K1 as the production starting
sensor. The passively received real-time `lio_pcl` plus `lio_pose` stream is
the primary K1 input for two separate derivatives: a short-TTL local world
model and a persistent territory reconstruction. `RAVNOVES00` is the immutable
private replay dataset for this work. No K1 firmware change, onboard exporter
or new device command is part of the LiDAR roadmap, and the historical
`1.27 m` handheld-height experiment is not a runtime constant.
The first passive replay derivative is now implemented as
`missioncore.k1-local-surface/v1`. It binds the immutable `RAVNOVES00` pack,
estimates a rolling local surface from map points plus compatible pose, and
publishes height, slope, roughness, confidence and conservative observed
surface/occupied/unknown evidence. The complete pack contains `4,489` timeline
frames, `3,928` available/valid LiDAR frames and `9,207,270` points; there were
no stale-pose, insufficient-surface or fit-failed frames. Prior-only
next-frame qualification produced `3,927` samples with `0.04065 m` p50 median
residual and `205` strict temporal jumps. The p95 residual tail remains too
large for planner use. The current complete derivative is the first item in
**Парк → Диагностика LiDAR**.
The earlier content-bound `526`-frame review slice remains available as
historical laboratory evidence. Its deterministic triage contains `37`
attention frames in `21` episodes and four high-priority frames. The selected
frame can switch to the prior-plane residual view; source frame `1254` has
`36/98` evaluated cells above the `0.16 m` band and none below it. This
localizes a positive residual structure without naming an object or granting
safety authority.
The same estimator now runs behind
`missioncore.k1-local-surface-shadow-runtime/v1`, a capacity-two
latest-wins queue with a bounded diagnostic result ring. LAB E27 passed a
`14.995 s` RAVNOVES00 slice at recorded 1× pace: `143/143` available frames
were consumed, maximum queue depth was `1/2`, no frame was replaced, p95
processing latency was `14.777 ms`, and state, point classes, step candidates
and scalar results matched the immutable replay derivative exactly. This is a
recorded-source-paced execution gate only; physical K1 acquisition,
free-space, commands, navigation and safety authority remain unavailable.
The authenticated external-worker seam is now implemented and replay-qualified.
It binds decoded map-frame `lio_pcl` and `lio_pose` in either arrival order
through
`missioncore.k1-local-surface-pose-binder/v1`, then feeds the accepted estimator
without blocking camera cadence. Point input and estimator work are
bounded separately: the authenticated ingress has LiDAR depth eight, while the
freshness-first estimator remains capacity two. LAB E28 replayed the complete
recording at 1× through the actual NVIDIA worker: all `4,570` LiDAR and `4,598`
pose events arrived with zero gaps, all point frames bound, and `4,206`
results were persisted at `9.1945 Hz`. Explicitly superseded work was
`364/4,570` (`7.965%`), p95 result age was `89.530 ms`, and all acceptance
checks passed. Physical K1 acquisition remains a separate commissioning test;
it is not required for replay algorithm development.
The complete RELLIS-3D v1.1 release is now admitted there and its full
`2,413`-frame validation split is available in **Полигон → Датасеты**. The
sealed Current/Patchwork++ comparison rejected Patchwork++ for navigation:
despite much lower latency and a small Ground-IoU gain, obstacle non-ground
recall regressed from `80.46%` to `69.70%`.
Operator review of the later M4.8R3 LOW-STEP replay rejected its connected-
component camera boxes as the product representation for general static
obstacles. The replacement evaluation is terrain-relative and sequential:
GSeg3D plus Nav2 Ground Consistency first, TRAVEL as the class-free challenger,
and nvblox only as a separately measured occupancy/ESDF candidate. See the
[traversability and static-obstacle stack decision](docs/23_TRAVERSABILITY_AND_STATIC_OBSTACLE_STACK_DECISION.md).
See [the Dataset Gateway plan](docs/14_LIDAR_DATASET_GATEWAY.md),
[the laboratory run canon](docs/15_LABORATORY_RUN_CANON.md) and
[ADR 0021](docs/adr/0021-dataset-gateway-representation-boundary.md).
## Simulation Polygon
Mission Core now has a parallel Polygon product branch for reproducible
autonomy qualification. Mission Core owns scenarios, qualification-run
lifecycle, authority, canonical contracts, provenance and reports; Gazebo, PX4
SITL, ROS 2, Nav2 and viewers remain replaceable providers. Simulation, replay,
digital twin, HIL and physical shadow keep distinct causal run kinds.
The provider boundary is now executable through
`missioncore.simulation-provider-profile/v1`: worker status declares provider
roles/capabilities, clock, ENU/FLU frames and supported control profiles.
Gazebo/PX4 stays the accepted regression profile. Unreal is evaluated as a
separate high-fidelity provider family; native Unreal, CARLA and Project AirSim
must pass ADR 0017 U0 before a production adapter is selected. Unreal runs on a
worker, never inside React; the existing browser scene, lifecycle, evidence and
safety contracts are retained.
SIM S0 is accepted on the reviewed D-only worker. Its strict profile fixes
loopback-only process isolation, exact provider pins, a 2 ms physics step,
1×/2× RTF/resource budgets and disabled real/direct actuator authority. Two
accepted-profile stock-rover runs and nine digest-bound evidence claims passed;
the target doctor returned `GO`. The doctor remains read-only and a development
host without the private evidence pack still returns `INCOMPLETE`:
```bash
uv run missioncore-sim s0 doctor \
--evidence /path/to/private/evidence.yaml \
--json
```
S1A now adds the first server-owned domain boundary: typed immutable
`QualificationRun` identities, legal lifecycle transitions, canonical
Ackermann/Differential commands, authority generation and TTL validation, and
an append-only crash-safe run/event/command/artifact repository. Active runs
recover as failed/interrupted rather than silently resuming, and reset creates
a new linked run/episode. This is repository-level lifecycle acceptance only;
PX4 command delivery, watchdog/failsafe behavior, navigation behavior and real
actuator control remain unaccepted.
The S1B repository increment adds the transport-neutral
`SimulationApplicationService`, an exact-S0/D-only worker admission guard and a
POSIX process supervisor. Start is idempotent, a second active owner is
rejected, provider processes receive dedicated PGIDs and run-scoped logs, and
shutdown follows the S0 dependency order with residue reported as failure.
Pause/resume/step/reset/stop are owned by the application service through a
worker port.
Commit `6cb1495` has now passed the real D-only `MissionCore-Sim` S1B lifecycle:
the exact immutable source generation started Micro XRCE-DDS Agent and the PX4
v1.17.0/Gazebo Harmonic stock Ackermann rover in a fresh loopback-only
namespace, admitted the run only after world/startup/DDS-writer health markers,
and stopped both owned PGIDs with zero residue. The persisted run reached
`completed`, contains eight lifecycle events and zero control commands. This
run is now visible through UI-0: a direct internal read-only Control Station
surface backed by the same append-only qualification repository. It exposes
identity, terminal reason, provider pins, events, command count, artifact
metadata and the virtual-only authority boundary. Live pause/resume/step/reset,
canonical telemetry, PX4 command mapping, watchdog/failsafe evidence and the
accepted command path remain gated.
S1C commit `b7ccca3` adds the first registered live-worker vertical without
collapsing that boundary. An unprivileged worker agent owns the existing
application service and providers inside a fresh loopback-only network
namespace. Mission Core reaches it through a mode-`0600` Unix socket; React
reaches only same-origin Polygon API routes. The live browser panel can
start/stop the stock rover and show worker/run/provider status. UI-2 commit
`60e7916` adds a worker-gated `Полигон` header root and a browser-native
Three.js Ackermann Rover scene with orbit, zoom, pan, follow and reset. The
deep-link remains for exact-run review; it is no longer the primary operator
entry. Gazebo dynamic-pose ground truth remains explicitly `diagnostic`, not
accepted PX4/ROS 2 telemetry.
The exact D-only UI-2 generation passed 35 target tests and a real UI-driven run
`s1c-60e7916-20260724t182006z-2f164e`: both providers reached readiness, the
browser received live `VehicleState`, the run reached `completed` after
27.74 seconds of Gazebo time, and shutdown left no PX4/Gazebo/XRCE process
residue. The run contained zero commands, so this result does not yet prove
rover motion, PX4 command delivery, watchdog/failsafe behavior, navigation or
safety acceptance.
S1D exact code generation `49b0f47` closes the first virtual Ackermann command
and motion slice. React sends bounded speed/steering intents through the
same-origin Mission Core API and private worker socket. The worker journals each
accepted command before mapping it to PX4 rover-level Offboard setpoints:
`RoverThrottleSetpoint` is calibrated against the stock model's
`RO_MAX_THR_SPEED=3.1`, `RoverSteeringSetpoint` carries normalized steering,
and PX4 retains arming, Offboard loss handling and control allocation. No
`ActuatorMotors` or `ActuatorServos` topic is exposed or published by Mission
Core.
The immutable D-only generation passed 40 focused target tests. Its first cold
start completed in one request after 32.08 seconds. Run
`s1c-49b0f47-20260724t195259z-62edd2` proved 3.74 m straight motion, a curved
turn, reverse motion, 44.6 ms sampled command admission, a 250 ms TTL transition
(`ttl_expired_count` 4 -> 5), stable stopped pose and clean provider shutdown
with no PX4/Gazebo/XRCE residue. This does not accept calibrated closed-loop
speed tracking, Differential Rover, canonical PX4/ROS 2 telemetry,
pause/step/reset, heartbeat/offboard/link-loss cases, Nav2, perception or safety
behavior. Real actuator authority remains false.
Start the worker from the exact immutable D source generation:
```bash
sudo simulation/s1/worker-agent.sh <exact-40-character-mission-core-commit>
```
Then configure the Mission Core backend. When the worker status is available,
open `Полигон` from the Control Station header:
```bash
export MISSIONCORE_POLYGON_RUNS_ROOT=/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/runs
export MISSIONCORE_POLYGON_WORKER_SOCKET=/run/missioncore-sim/worker.sock
export MISSIONCORE_POLYGON_WORKER_CONTROL=internal-virtual-only
export MISSIONCORE_COMMIT=<exact-40-character-mission-core-commit>
uv run k1link serve
```
```text
http://127.0.0.1:8000/
```
The direct `?workspace=polygon-run&run=<qualification-run-id>` URL remains a
compatible evidence deep-link. The archive API remains GET-only. The worker API
adds status/live GETs and explicit start, command and stop POSTs; POSTs remain
`403` unless the backend has the `internal-virtual-only` gate and an exact
commit. Missing configuration, transport drift and corrupt evidence fail
closed. The browser receives no Unix socket, D root, artifact bytes, PX4
endpoint or direct provider command.
Do not launch a second integrated backend on another port when `8000` is
already occupied. Stop or restart the stale Mission Core process on `8000`.
TCP `8765` remains reserved for the legacy Foxglove regression module and is
not a Control Station operator path.
See the [Polygon product/SRS](docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md),
[ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.md),
[ADR 0016](docs/adr/0016-distributed-product-edge-and-worker-topology.md),
[ADR 0017](docs/adr/0017-provider-neutral-simulation-and-unreal-evaluation.md)
and the
[SIM S0 worker runbook](docs/runbooks/SIM_S0_AI_WORKER.md). The live-worker
launch and acceptance procedure is in
[SIM S1C live-worker runbook](docs/runbooks/SIM_S1C_LIVE_WORKER.md).
Mission Core remains a distributed browser product rather than a
platform-specific desktop executable. React runs in the operator browser;
Gateway/API and Control Plane run as long-lived services; Gazebo/PX4/ROS 2/Nav2
run headless on a registered simulation worker; and a future field vehicle runs
a headless onboard Linux Edge Agent with local-first evidence and
store-and-forward synchronization. Native Gazebo GUI is worker-local
diagnostics. The accepted live rover scene uses canonical state in Three.js;
Rerun remains the recorded spatial-evidence path. Field execution never depends
on streaming a simulator desktop.
## Mission Core Control Station and visualization adapters
The browser application is the universal Mission Core Control Station rather than
a K1-specific Foxglove launcher. Its fixed shell contains Center, Fleet,
Observation, Missions, Data and System; a seventh Polygon section is inserted
only while an admitted Simulation Worker is available. The K1
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter. Its
React provisioning, acquisition/replay and diagnostic blocks live beside the
plugin manifest under `plugins/xgrids-k1/frontend`; the generic application
mounts them through one reviewed composition import.
Install, type-check, build and serve the complete local application from the
repository root:
```bash
uv sync --group dev
cd apps/k1-viewer
npm install
uv sync --frozen --group dev
cd apps/control-station
npm ci
npm run test:unit
npm run typecheck
npm run build
cd ../..
@@ -80,45 +381,149 @@ uv run k1link serve
Open `http://127.0.0.1:8000`. The static application, REST/WebSocket control
plane and credential endpoint bind to loopback only. The current K1 adapter
still provides real CoreBluetooth discovery, one operator-triggered reviewed
BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and
reviewed-TSV replay, raw-first evidence storage and measured preview metrics.
Physical K1 scanning is still started and stopped by the verified double-click;
the connector publishes no modeling command.
BLE Wi-Fi provisioning write, a one-shot CoreWLAN K1-AP association path,
read-only MQTT live capture, native `.k1mqtt` and
reviewed-TSV replay, raw-first evidence storage, device-reported scan
time/distance/speed and measured preview metrics. Its frontend contribution now
also owns an optional spatial-scene control block, including acquisition phase,
telemetry and the stop action. Operator-manual acquisition still finalizes only
local reception; plugin-commanded v0.5.0 acquisition uses the separately gated
canonical K1 START/STOP dialogue.
Plugin v0.7.0 supervises the v0.6.0 local connection matrix with separate
desired, configured and active modes plus exact DeviceInfo-backed Ready.
Bridge remains the default and accepted product path; Direct Connect sends the reviewed station
provisioning frame for an already-running controller hotspot. Quick Connect
sends one separately reviewed AP-enable frame and can associate a prepared Mac
through CoreWLAN. Its device activation and prepared-host association were
physically accepted, but credential bootstrap is not portable: a clean host
cannot acquire the firmware-defined AP material from the reviewed BLE protocol.
Quick Connect therefore remains laboratory functionality, with no automatic
firmware download, iPhone extraction or hard-coded fallback. Each mode has a
distinct topology attestation and no automatic retry; see
[ADR 0013](docs/adr/0013-k1-local-connection-matrix.md).
The exact recovered `ModelingRequest` start/stop encoder, response correlator
and device-status state machine are installed in the operator-gated interactive
control path.
The plugin also contains the exact ten-request pre-START bootstrap recovered
from retained LixelGO traffic. Its first `DeviceInfoRequest` has no device ID;
the response supplies live vendor identity, serial, model, activation and
version facts for the BLE-selected K1. Static and wire evidence identify OpenAPI
as one private application-level value in the observed client, not a manual
per-scanner profile. The whole pre-START order and START/STOP payloads are
byte-matched offline. A fixed macOS Keychain loader now supplies the exact
application authority without env/file/browser fallback, and a five-batch
orchestrator passes all retained response barriers. The legacy shadow publisher
boundary remains structurally write-disabled and cannot call its sink. Its
dormant coordinator can hold authority
for at most 300 seconds and expiry/disarm/reprovision/acquisition/shutdown revoke
it together with the orchestrator. Plugin v0.5.0 installs a separate
physical-acceptance transport reproducing MQTT 3.1.1 `clean_session=false`,
keepalive 60, exact
response subscriptions, QoS2 completion and the five response-gated batches.
It consumes operation keys before publish and poisons unknown outcomes without
retry. One background owner services that socket through response-gated
connection, workspace, project, START, STOP and device-standby stages. The normal
UI collapses only the pre-START controls into one explicit operator intent;
navigation cannot emit a command. The first operator-present physical attempt on 2026-07-18 emitted only
the first six bootstrap requests, then failed closed while correlating the third
response batch; START was not emitted. A second one-shot attempt correlated all
ten pre-START requests and the START acknowledgement, but incorrectly collapsed
the whole exchange into 365 ms and ended the control lifecycle before the
retained post-START reads. K1 stayed in `SCAN_STARTING` for about 24 seconds,
reported `ALGORITHM_ERROR` twice, emitted no point/pose data and showed steady
red until an operator power cycle restored steady green. The collapsed executor
is now disabled. The offline acceptance executor maps connection requests 1–6,
workspace-entry request 7, project-prompt reads 8–10, user-confirmed START 11,
immediate status read 12 and state-gated reads 13–14 onto one continuously owned
MQTT session. No captured operator delay is a protocol timer: the final reads
wait for live `SCANNING`, project binding and `init_ready`, while STOP/save waits
for live unbound `READY`. Standalone START and STOP
are both rejected. The same socket is serviced throughout launch and STOP
confirmation, and no captured human delay is replayed as a protocol timer.
Plugin v0.5.0 wires this executor to the facade and plugin UI. The full staged
START/live/STOP/save cycle is physically accepted on the reviewed unit. START
and STOP each retain an explicit operator action; final local sealing now follows
the scanner's protocol-reported unbound READY without a redundant second click.
This locked bootstrap is repeatable in the current workspace, not yet a
standalone release install. The frontend consumes sibling `file:` packages from
`NODEDC_DESIGN_GUIDELINE`; `package-lock.json` does not pin that checkout's Git
revision or content hash. Publishing/vendoring those packages or enforcing an
immutable donor revision remains a packaging and CI prerequisite.
Polygon is implemented as a capability-gated seventh section. When Mission Core
confirms an admitted Simulation Worker, its header button opens one direct
workspace with live 3D inspection and the server-owned qualification
repository. `?workspace=polygon-run[&run=<id>]` remains a compatibility
deep-link. Command and safety acceptance remain separate gates.
The Observation spatial workspace embeds the open-source Rerun Web Viewer
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.
inside the Mission Core shell. It can open a compatible RRD over same-origin
HTTP or a Rerun gRPC/proxy source such as
`rerun+http://127.0.0.1:9876/proxy`; no external hosted viewer UI is used.
Dynamic point-cloud/camera composition and the live source contract are fixed in
[`ADR 0006`](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md).
The durable session catalog, recorded `session_time` scrubber and versioned
workspace-layout profile are fixed in
[`ADR 0008`](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md).
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.
The first K1 live session or adapter file-replay (`.k1mqtt`/reviewed TSV) 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 live/file-replay sessions reset their session-local
scene and metrics and reuse that process-wide stream. Saved observation sessions
do not reuse this listener: they open a private digest-bound cache-v9 RRD
generation over same-origin HTTP through Rerun's native incremental receiver.
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 applicable source to the embedded viewer. A generic host action clears a
previous manual/archive selection only after a plugin-owned live or file-replay
start succeeds; a failed start leaves the current scene mounted. A fail-closed
guard blocks operator source switches for every nonterminal acquisition. The
complete visual live runtime path is K1 MQTT → raw-first evidence capture →
bounded four-message latest-wins preview queue → explicitly injected K1
protobuf/LZ4 normalizer → transport-neutral decoded local views → Rerun
`Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer. Rerun does
not inspect K1 topics or raw payloads. These local decoded views are not yet the
portable Plugin SDK wire envelopes. K1 `ModelingReport` status is consumed by an
explicitly injected pre-preview observer, so scan telemetry cannot evict point
or pose frames from that four-message queue.
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.
point and trajectory visibility, and the scene grid. The first disk action saves
and restores the versioned spatial layout without mutating sensor evidence.
Saved native point/pose sessions are materialized losslessly into private,
digest-bound RRD recordings and can be played, paused and scrubbed on a
zero-based `session_time` timeline. New captures durably publish a clock origin
before camera production, retain a transport-scoped provisional envelope and
atomically point their summary to a content-addressed session envelope after all
producers stop. That session envelope becomes the real RRD origin/end range;
archived `ModelingReport` distance, speed and scan time are logged as scalar
time series. 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.
cloud and zero decode errors. The later RTSP camera preview is available through
the generic floating observation windows and new acquisitions archive its fMP4
segments independently of browser delivery. Historical sessions recorded before
that archive contract contain no video. A real archived session containing one
camera plus point cloud has not yet passed physical shared-timeline playback;
the implementation and test contract do not close that hardware gate. Rerun
`capture_time` and the camera index use Mac receive/arrival timestamps, not
proven K1 sensor timestamps or a photon-to-screen measurement.
The old Foxglove implementation is retained only in
`src/k1link/viewer/foxglove_bridge.py` and its regression tests. The current
`src/k1link/device_plugins/xgrids_k1/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).
[`apps/control-station/README.md`](apps/control-station/README.md).
The host storage layout, recovery rules, replay API and operator path are in
[`docs/09_OBSERVATION_SESSIONS.md`](docs/09_OBSERVATION_SESSIONS.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
@@ -165,12 +570,36 @@ present.
- [Technical audit](docs/00_TECHNICAL_AUDIT.md)
- [Implementation gates](docs/01_IMPLEMENTATION_PLAN.md)
- [Architecture audit execution roadmap](docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md)
- [Laboratory run canon](docs/15_LABORATORY_RUN_CANON.md)
- [Product UI and laboratory presentation canon](docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md)
- [Application component architecture](docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md)
- [Product surface extension protocol](docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md)
- [First lab runbook](docs/02_FIRST_LAB_RUNBOOK.md)
- [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 embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md)
- [Mission Core monorepo and plugin boundary](docs/07_MISSION_CORE_MONOREPO.md)
- [Owner-controlled LixelGO/iPhone observation](docs/08_LIXELGO_IPHONE_OBSERVATION.md)
- [K1 application-control physical acceptance](docs/lab/003_K1_CONTROL_ACCEPTANCE_20260718.redacted.md)
- [Observation sessions, playback and workspace layout](docs/09_OBSERVATION_SESSIONS.md)
- [Monorepo architecture decision](docs/adr/0002-mission-core-monorepo.md)
- [Device plugin UI and runtime boundary](docs/adr/0003-device-plugin-ui-and-runtime-boundary.md)
- [Plugin SDK v0alpha2 and experimental device lifecycle](docs/adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md)
- [Owner-controlled LixelGO observation decision](docs/adr/0005-owner-controlled-lixelgo-iphone-observation.md)
- [Vendor-neutral live observation sources](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md)
- [K1 camera preview copy-remux gateway](docs/adr/0007-k1-camera-preview-copy-remux-gateway.md)
- [Durable observation sessions and workspace layout](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md)
- [Device-plugin observation runtime and K1 extraction](docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md)
- [Plugin-owned frontend device workflows](docs/adr/0010-plugin-owned-frontend-device-workflows.md)
- [Laboratory plugin runtime handshake and transport seam](docs/adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md)
- [Device-bound K1 command authority](docs/adr/0012-device-bound-k1-command-authority.md)
- [Simulation Polygon qualification boundary](docs/adr/0015-simulation-polygon-qualification-boundary.md)
- [Distributed product, edge and worker topology](docs/adr/0016-distributed-product-edge-and-worker-topology.md)
- [Sensor representation capabilities and free-space admission](docs/adr/0023-sensor-representation-capabilities-and-free-space-admission.md)
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
- [Canonical control and durable archive milestone](docs/lab/004_K1_CANONICAL_CONTROL_ARCHIVE_20260719.redacted.md)
- [Session manifest schema](schemas/session-manifest.schema.json)
- [Reference input provenance](docs/reference/README.md)
@@ -186,13 +615,15 @@ capture of traffic to or from the confirmed K1 address, and offline analysis of
owned artifacts.
The reviewed provisioning write requires its named profile and explicit operator
confirmation. Application command publishing remains disabled: physical
double-click is the verified start/stop mechanism. Any future MQTT publisher,
router configuration change or new BLE write requires its own evidence and
confirmation. The interactive application-control publisher is enabled only for
the exact K1/A4/FW 3.0.2 direct-LAN profile and only inside one operator-opened,
response-gated session; physical double-click remains the independent fallback.
The legacy shadow publisher remains structurally disabled. Any broader firmware,
topology, scanner-family or command surface requires its own evidence and
reviewed step. Random writes, fuzzing, brute force, firmware operations,
destructive file access and credential guessing remain out of scope.
Real captures, projects, router metadata, serials, credentials, maps, images,
and logs are ignored by normal Git. Redacted manifests and SHA-256 inventories
are committed; encrypted artifact storage will be selected only when real data
exists.
and logs under `.runtime/`, canonical evidence roots and legacy `sessions/` are
ignored by normal Git. Redacted manifests and SHA-256 inventories are committed;
encrypted/replicated artifact storage remains a deployment decision.
+7
View File
@@ -0,0 +1,7 @@
# Mission Core applications
- `control-station/` — the current browser operator application.
Applications consume Mission Core contracts and capabilities. Device-specific
transport and codecs belong under `plugins/`, not inside application routing
or navigation.
+356
View File
@@ -0,0 +1,356 @@
# NODEDC MISSION CORE · Control Station
Универсальный браузерный пункт управления Mission Core на React 19, TypeScript и
Vite. Приложение задаёт общую операторскую оболочку для аппаратов, сенсоров,
наблюдения, миссий и записей. XGRIDS/LixelKity K1 является первым реальным
device adapter, но структура интерфейса от него не зависит.
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
переход во внешний облачный интерфейс. Для K1 live и adapter file-replay backend
сам создаёт Rerun gRPC/proxy source. Saved observation sessions открывают
проверенную digest-bound RRD generation через same-origin HTTP; ручной адрес
нужен только для другого совместимого Rerun source.
## Текущее состояние
| Контур | Состояние | Что это означает |
| --- | --- | --- |
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
| Device plugin registry | Реализован, v1alpha1 + v1alpha2 | До выбора модели provider остаётся inert и не делает I/O. v1alpha1 сохраняет одну модель; v1alpha2 допускает одну или несколько моделей и требует profile coverage каждой. Custom `device.connection` UI key и backend factory подключаются одним reviewed import в composition root. |
| Plugin-owned device UI | Реализован | XGRIDS provisioning, project/acquisition/replay, diagnostics, metrics, optional spatial controls и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет frontend SDK, model catalog и host slots. |
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
| K1 live/replay MQTT | Реализован | Read-only приём, обязательное локальное имя проекта, raw-first сохранение, декодирование облака/позы и device-reported scan time/distance/speed. |
| Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. |
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. |
| Сохранённые observation sessions | Реализованы для point/pose/device metrics | Три последние сессии, background preparation, capture-clock-bound cache v9, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но point-cloud + one-camera archived playback ещё не прошёл physical acceptance. |
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
Приложение не генерирует демонстрационное облако, траекторию, кадры или
метрики. Если реальных данных нет, область сцены остаётся пустой, а числовые поля
показывают `—`.
## Архитектура данных
```text
K1 MQTT :1883, read-only
└── raw .k1mqtt + metadata + durable clock origin сохраняются первыми
├── injected ModelingReport observer -> device scan time/distance/speed
└── bounded latest-wins visual preview queue (4 сообщения)
└── явно внедрённый K1 protobuf/LZ4 point/pose normalizer
└── transport-neutral DecodedPointCloudView / DecodedPoseView
└── Rerun Points3D + Transform3D + LineStrips3D
└── gRPC/proxy TCP 9876
└── rerun_grpc_url в REST/WebSocket state
└── встроенный @rerun-io/web-viewer
└── пространственная сцена Mission Core
Mission Core Control Station ←→ REST /api/v1/device-plugins/*
+ plugin-scoped WebSocket events
FastAPI на 127.0.0.1:8000
CoreBluetooth + live/replay runtime
sealed/recovered observation session
└── SQLite catalog + bounded background preparation
└── capture-clock-bound atomic RRD cache v9 + recorded-media manifest v2
└── generation-bound same-origin HTTP
└── aggregate admission
└── Rerun native receiver + recorded fMP4 player
```
В момент готовности live/file-replay `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 держит их пустыми.
## Фиксированная оболочка
Shell собран из локальных NODE.DC UI packages и сохраняет одну структуру для
всех функциональных модулей:
1. `AppHeader` — марка NODE DC, выбор архитектурного раздела и состояние
локального backend.
2. `AdminNavigationPanel` — контекст аппарата и список рабочих поверхностей
выбранного раздела.
3. `LandingStage` — стартовая ситуационная поверхность и быстрые переходы.
4. `ApplicationPanel` — единый контейнер активной рабочей поверхности.
5. `Window` и `Inspector` — источник, отображение, слои и компоновка без
раскрытия внутренних панелей визуального движка.
Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя,
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по
умолчанию накопление, видимость облака и траектории и сетка связаны с backend и
Rerun Blueprint. Host timeline управляет сохранённым `session_time`, а spatial
layout сохраняет display settings, tool windows и source-window geometry через
revisioned API. Смена 2D/3D/карты и семантические слои пока остаются
интерфейсным контрактом.
## Архитектурные разделы
Навигация описана данными в `src/productModel.ts`, а не зашита в разметку каждой
страницы.
| Раздел | Назначение |
| --- | --- |
| Центр | Живое состояние вычислительных узлов, процессов, сети и подключённых устройств. |
| Парк | Аппараты, текущее устройство, сенсоры и конфигурации борта. |
| Наблюдение | Только live-пространственная сцена, камеры, карта, объекты, телеметрия и время. |
| Миссии | Планировщик, маршруты, сценарии и исполнение. Командный backend отключён. |
| Данные | Сохранённые сессии, replay, публичные датасеты, потоки, сущности и экспорт. |
| Система | Модули, интеграции, сеть, аудит и настройки платформы. |
| Тестировочный контур | Архив лабораторных работ, конфигурации экспериментов и визуальные результаты. |
Карточки возможностей имеют четыре честных уровня: работает сейчас, готово к
источнику, интерфейсный контракт и последующий этап. Каталог не следует читать
как утверждение, что для каждой карточки уже существует backend.
## Установка, проверка, сборка и запуск
Требуются Node.js 20.19+ либо 22.12+, `uv` и соседний checkout
`NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через
локальные `file:` пути. Python устанавливается только в `.venv` репозитория.
Канонический путь от корня репозитория:
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
uv sync --frozen --group dev
cd apps/control-station
npm ci
npm run test:unit
npm run typecheck
npm run build
cd ../..
uv run k1link serve
```
Открыть `http://127.0.0.1:8000`. Команда `serve` отдаёт собранный `dist/` и
локальный API. Она намеренно привязана только к `127.0.0.1`; LAN bind не
предусмотрен, потому что endpoint подключения кратковременно принимает пароль
Wi-Fi.
Этот locked bootstrap повторяем в текущем workspace, но он ещё не является
standalone release install. Локальные `file:` зависимости берутся из соседнего
`NODEDC_DESIGN_GUIDELINE`, а lockfile не фиксирует Git revision или content hash
этого checkout. До CI/portable packaging эти пакеты нужно опубликовать,
завендорить либо проверять по неизменяемой donor revision.
Для разработки интерфейса после запуска backend:
```bash
cd apps/control-station
npm run dev
```
Для полного live-viewer контура Vite должен открыться на
`http://127.0.0.1:5173`: только 5173, production preview 4173 и backend 8000
входят в текущий Rerun CORS allowlist. Если Vite сообщает fallback на 5174 или
выше, освободите 5173 и перезапустите `npm run dev`; REST/WebSocket proxy на
fallback-порту может работать, но прямой browser → Rerun 9876 будет отклонён.
Vite проксирует весь `/api` (включая WebSocket) на `http://127.0.0.1:8000`.
Другой локальный backend можно указать переменной `VITE_API_TARGET`. Preview
production-сборки запускается командой `npm run preview` на
`http://127.0.0.1:4173`.
## Операторский путь для текущего K1 adapter
1. Запустить `uv run k1link serve` и открыть Mission Core Control Station.
2. Выбрать **Парк → Локальное устройство**.
3. В каталоге моделей выбрать **XGRIDS LixelKity K1**. Только после этого
активируется runtime и монтируется custom UI XGRIDS-плагина.
4. Включить K1, дождаться стабильного индикатора, вручную сверить firmware
`3.0.2` и direct-LAN топологию, затем подтвердить это в форме. Mission Core
не читает firmware с устройства; такое подтверждение остаётся
`operator-attested`, а не device-derived evidence.
5. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
шестисекундного поиска; метка совместимости является подсказкой, выбор делает
оператор.
6. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
reviewed provisioning-запись без автоматических повторов.
7. Ввести обязательное название проекта. Оно NFKC-нормализуется, trim-ится,
проверяется на control/surrogate characters и лимит 96 Unicode characters,
сохраняется как display metadata и не используется в filesystem path.
8. Запустить локальный live-приём по определённому адресу K1 либо replay
локального `.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается
и останавливается подтверждённым двойным нажатием кнопки устройства.
9. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
state. Ручной source вводить не требуется.
10. Открыть **Наблюдение → Пространственная сцена**. Plugin-owned spatial block
показывает local acquisition phase, stop local reception и полученные от K1
scan time/distance/speed; облако, траектория и preview metrics появляются
только после реальных сообщений.
11. Физически остановить K1, дождаться steady green, затем остановить локальный
приём из device workflow или spatial block, чтобы запечатать evidence.
12. После нормального stop или recovery открыть **Данные → Сессии и записи**. Дождаться
состояния **Готово**, выбрать запись и использовать host timeline. Evidence
сохраняется автоматически; disk action сохраняет только workspace layout.
В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и
0 ошибок декодирования.
## Источники Rerun
Live/replay runtime автоматически назначает первый URL из примера. В
**Наблюдение → Пространственная сцена → Источник** можно оставить ручное поле
пустым либо указать другой адрес, который понимает `@rerun-io/web-viewer`
зафиксированной в `package.json` версии:
```text
rerun+http://127.0.0.1:9876/proxy
http://127.0.0.1:8080/recording.rrd
https://example.internal/recording.rrd
```
- `rerun+http://…/proxy` — живой Rerun gRPC source через доступный браузеру
proxy;
- `http(s)://…/recording.rrd` — RRD-запись по HTTP(S);
- версия RRD должна быть совместима с версией Web Viewer;
- ручной URL хранится только в состоянии текущей страницы и имеет приоритет над
автоматическим source;
- nonterminal acquisition fail-closed блокирует manual source apply/reset,
saved session switch и persisted replay reattach до завершения текущего
приёма; automatic-source action очищает старый source и открывает сцену только
после successful start, failed start сохраняет текущую сцену, а guard не
останавливает устройство;
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
оболочку. 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 сейчас означает выбранный сплошной цвет,
а не готовую семантическую классификацию.
Для saved session host timeline вызывает Rerun play/pause/seek только после
полного admission. Accumulation и display settings применяются через отдельный
маленький blueprint RRD и не заменяют основную запись. Versioned
`observation.spatial` layout сохраняется на host с optimistic revision; он не
содержит sensor evidence. Переключатель 2D/3D/карты и семантические слои пока не
вызывают готовый presentation backend.
## Контракт локального API
| Метод | Route | Тело / назначение |
| --- | --- | --- |
| `GET` | `/api/health` | Проверка локального сервиса. |
| `GET` | `/api/v1/device-plugins` | Валидированные manifests установленных device plugins. |
| `GET` | `/api/v1/device-models` | Backend-каталог моделей из валидированных manifests; UI-каталог текущей сборки формируется отдельным static composition root. |
| `POST` | `/api/v1/device-plugins/{pluginId}/actions/{actionId}` | Namespaced действие через host allowlist; тело `{ "input": { ... } }`. |
| `WS` | `/api/v1/device-plugins/{pluginId}/events` | Plugin-scoped snapshots с `pluginId` и монотонным `sequence`. |
| `GET` | `/api/v1/observation-sessions` | Последние cataloged sessions и authoritative preparation state. |
| `POST` | `/api/v1/observation-sessions/{id}/replay` | Verified replay launch либо HTTP 202 preparation handle. |
| `GET` | `/api/v1/observation-sessions/{id}/recording-preparation` | Exact-job polling с `If-Match`. |
| `GET` | `/api/v1/observation-sessions/{id}/recording.rrd` | Immutable generation-bound RRD; arbitrary chunked `send_rrd` не используется. |
| `GET` | `/api/v1/observation-sessions/{id}/media/{artifact}/...` | Manifest/init/segments записанных камер по opaque identifiers. |
| `GET`, `PUT` | `/api/v1/workspace-layouts/observation.spatial` | Versioned host layout с optimistic revision. |
Frontend XGRIDS-плагина использует только v1alpha namespaced routes. Старые
`/api/state`, `/api/events`, `/api/ble/scan`, `/api/connect`, `/api/session/*` и
`/api/viewer/settings` сохранены как deprecated compatibility shims внутри
XGRIDS backend contribution.
В v1alpha1/v1alpha2 backend composition загружается из manifests, а frontend plugins
статически включаются в сборку через `src/composition/devicePlugins.ts`.
Автоматической runtime-сверки двух installed sets пока нет: их соответствие —
проверяемое требование сборки до появления подписанных plugin bundles и startup
compatibility handshake. Поля manifest `permissions`, `mutating` и
`secretFields` пока являются декларативными метаданными; generic host валидирует
их форму и action allowlist, но ещё не реализует на их основе RBAC, подтверждения
оператора или secret-vault substitution.
Точный K1 compatibility profile остаётся неактивным, пока оператор явно не
подтвердит firmware `3.0.2` и direct-LAN топологию. Runtime не получает версию
firmware с устройства и явно помечает основание как `operator-attested`.
Ошибки валидации namespaced action endpoint и deprecated `/api/connect`
возвращаются как общий `422` без исходных значений запроса, поэтому отклонённое
значение credential-поля не отражается клиенту.
Legacy `/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
как `{ "state": { ... } }`. v1alpha2 snapshot отдельно содержит `device_ref`,
`device_session`, `acquisition`, `operations`, compatibility/calibration/sensor
facts и старые presentation-поля `phase`, `message`, `devices`,
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
пустыми. OpenAPI доступен по `/api/docs`.
## Карта исходников frontend
| Файл | Ответственность |
| --- | --- |
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
| `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle, plugin host и public frontend SDK surface. |
| `src/core/runtime/` | Нормализованное состояние активного устройства, spatial source и fail-closed source-switch guard. |
| `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. |
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. |
| `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, project/acquisition/replay UI, optional spatial controls, API client, runtime mapper и scoped styles. |
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
| `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. |
| `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
| `src/components/RecordedFmp4Player.tsx` | Проверенный generation-bound MSE playback архивных камер. |
| `src/core/observation/sessionArchive.ts` | Строгий wire contract catalog/preparation/replay/media API. |
| `src/core/observation/recordedSessionAdmission.ts` | Общий RRD/camera gate до публикации recorded workspace. |
| `src/core/observation/workspaceLayout.ts` | Versioned spatial layout и optimistic revision. |
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
| `src/presentation.ts` | Vendor-neutral подписи lifecycle и форматирование нормализованных метрик. |
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
## Safety и чувствительные данные
- Mission Core 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.
- Bridge и Direct Connect выполняют только отдельно рассмотренную 99-байтную
provisioning-запись. Quick Connect выполняет отдельную рассмотренную
100-байтную AP-enable запись и после AP-ready допускает ровно одну системную
ассоциацию с заранее материализованным exact-firmware профилем. Пароль не
попадает в браузер/API/argv; системный Wi-Fi Keychain, post-write prompt и
автоматические повторы запрещены.
- MQTT data live/replay остаётся subscribe-only. Команды изолированы в
plugin-owned canonical control session и доступны только через отдельные
operator-present UI checkpoints.
- Exact start/stop codec, response correlator и device-status state machine не
владеют transport. Plugin v0.5.0 связывает их отдельным single-owner MQTT
контуром без wall-clock переходов, reconnect или automatic retry; после STOP
обязательны live READY и подтверждённый постоянный зелёный индикатор.
- Live-сессии сначала сохраняют сырые сообщения и camera segments, затем
формируют disposable preview. При перегрузке preview может быть отброшен,
native evidence сохраняется. MQTT durability имеет bounded group-commit RPO,
camera durability — текущий незавершённый fragment RPO; это не zero-loss claim.
- Новая session summary hash-bind-ит durable clock origin и атомарно переключает
pointer с provisional `mqtt.timeline.json` на content-addressed
`mqtt.timeline.session-<sha256>.json`: только owner-sealed `session` scope
может рекламировать combined media replay. Cache v9 создаёт реальные
origin/end RRD rows; provisional `transport` scope fails closed.
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
накопления viewer использует session-local `stream_time`. `capture_time` — не
доказанный timestamp сенсора K1 и не photon-to-screen latency.
- Панорамный камерный поток в MQTT report topics отсутствует; отдельные
left/right RTSP preview доступны через generic camera windows. LiDAR и camera
сейчас синхронизированы только по host arrival, не по доказанным sensor clocks.
Физический archived playback point cloud + одна выбранная камера ещё не принят.
- `.runtime/`, canonical evidence roots и legacy `sessions/` игнорируются Git и
могут содержать адреса, изображения, идентификаторы, траекторию и карту
помещения. В репозиторий попадают только код, тесты, redacted manifests и
безопасная документация.
@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Пункт управления NODE.DC — наблюдение, данные и планирование миссий беспилотных аппаратов."
content="NODEDC MISSION CORE — наблюдение, данные и планирование миссий беспилотных аппаратов."
/>
<meta name="theme-color" content="#08090b" />
<title>NODE.DC · Пункт управления</title>
<title>NODEDC MISSION CORE</title>
</head>
<body>
<div id="root"></div>
@@ -1,52 +1,77 @@
{
"name": "@nodedc/k1-viewer",
"name": "@nodedc/mission-core-control-station",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@nodedc/k1-viewer",
"name": "@nodedc/mission-core-control-station",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@noble/hashes": "^2.2.0",
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
"@nodedc/page-patterns": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns",
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1",
"playcanvas": "2.21.4",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"three": "0.185.1"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@types/three": "0.185.1",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.0",
"vite-plugin-wasm": "^3.6.0"
},
"engines": {
"node": ">=20"
"node": "^20.19.0 || >=22.12.0"
}
},
"../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react": {
"name": "@nodedc/map-cesium-react",
"version": "0.1.0",
"dependencies": {
"cesium": "1.143.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"react": "^19.1.0"
},
"peerDependencies": {
"react": ">=18"
}
},
"../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns": {
"name": "@nodedc/page-patterns",
"version": "0.1.0"
},
"../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": {
"name": "@nodedc/tokens",
"version": "0.6.0"
},
"../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core": {
"name": "@nodedc/ui-core",
"version": "0.6.0",
"version": "0.7.0",
"dependencies": {
"@nodedc/tokens": "0.6.0"
}
},
"../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react": {
"name": "@nodedc/ui-react",
"version": "0.6.0",
"version": "0.7.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@nodedc/ui-core": "0.6.0",
"@nodedc/ui-core": "0.7.0",
"lucide-react": "^0.468.0"
},
"devDependencies": {
@@ -342,6 +367,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -834,6 +866,26 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodedc/map-cesium-react": {
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
"link": true
},
"node_modules/@nodedc/page-patterns": {
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns",
"link": true
},
"node_modules/@nodedc/tokens": {
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"link": true
@@ -1209,6 +1261,13 @@
"win32"
]
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1291,6 +1350,34 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.1.tgz",
"integrity": "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.2",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1312,6 +1399,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.72",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
"integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
"license": "BSD-3-Clause"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.43",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
@@ -1489,6 +1582,13 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1557,6 +1657,13 @@
"yallist": "^3.0.2"
}
},
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"dev": true,
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1613,6 +1720,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playcanvas": {
"version": "2.21.4",
"resolved": "https://registry.npmjs.org/playcanvas/-/playcanvas-2.21.4.tgz",
"integrity": "sha512-L4UGy3z/YT8AaNBEY4ITsZup548UFMabF7loh0YQ0DRwQLdjIW8grmZPoQ1+B83+N6sejRS0/XlXV2Vfb11+fw==",
"license": "MIT",
"dependencies": {
"@types/webxr": "^0.5.24",
"@webgpu/types": "^0.1.70"
},
"engines": {
"node": ">=18.3.0"
}
},
"node_modules/postcss": {
"version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
@@ -1744,6 +1864,12 @@
"node": ">=0.10.0"
}
},
"node_modules/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1,32 +1,40 @@
{
"name": "@nodedc/k1-viewer",
"name": "@nodedc/mission-core-control-station",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test:unit": "node --test test/*.test.mjs",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@noble/hashes": "^2.2.0",
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
"@nodedc/page-patterns": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns",
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1",
"playcanvas": "2.21.4",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"three": "0.185.1"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@types/three": "0.185.1",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.0",
"vite-plugin-wasm": "^3.6.0"
},
"engines": {
"node": ">=20"
"node": "^20.19.0 || >=22.12.0"
}
}

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 322 B

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1,8 @@
# Draco glTF decoder 1.5.7
Vendored from Google's immutable versioned Draco decoder distribution for the
PlayCanvas `DracoDecoderModule`. Runtime loading remains local to Mission Core.
- `draco_wasm_wrapper_gltf.js`: `8bb2952d2ba7d67e1414f8df819410cb0434a666be53f671fff75f68843d76f6`
- `draco_decoder_gltf.wasm`: `712db3449ae2041d6e8a224c395bda6cedb49e51322fae38b7db9beb8b381889`
- Upstream base: `https://www.gstatic.com/draco/versioned/decoders/1.5.7/`
@@ -0,0 +1,116 @@
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.arrayIteratorImpl=function(h){var n=0;return function(){return n<h.length?{done:!1,value:h[n++]}:{done:!0}}};$jscomp.arrayIterator=function(h){return{next:$jscomp.arrayIteratorImpl(h)}};$jscomp.makeIterator=function(h){var n="undefined"!=typeof Symbol&&Symbol.iterator&&h[Symbol.iterator];return n?n.call(h):$jscomp.arrayIterator(h)};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;
$jscomp.ISOLATE_POLYFILLS=!1;$jscomp.FORCE_POLYFILL_PROMISE=!1;$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION=!1;$jscomp.getGlobal=function(h){h=["object"==typeof globalThis&&globalThis,h,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var n=0;n<h.length;++n){var k=h[n];if(k&&k.Math==Math)return k}throw Error("Cannot find global object");};$jscomp.global=$jscomp.getGlobal(this);
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(h,n,k){if(h==Array.prototype||h==Object.prototype)return h;h[n]=k.value;return h};$jscomp.IS_SYMBOL_NATIVE="function"===typeof Symbol&&"symbol"===typeof Symbol("x");$jscomp.TRUST_ES6_POLYFILLS=!$jscomp.ISOLATE_POLYFILLS||$jscomp.IS_SYMBOL_NATIVE;$jscomp.polyfills={};$jscomp.propertyToPolyfillSymbol={};$jscomp.POLYFILL_PREFIX="$jscp$";
var $jscomp$lookupPolyfilledValue=function(h,n){var k=$jscomp.propertyToPolyfillSymbol[n];if(null==k)return h[n];k=h[k];return void 0!==k?k:h[n]};$jscomp.polyfill=function(h,n,k,p){n&&($jscomp.ISOLATE_POLYFILLS?$jscomp.polyfillIsolated(h,n,k,p):$jscomp.polyfillUnisolated(h,n,k,p))};
$jscomp.polyfillUnisolated=function(h,n,k,p){k=$jscomp.global;h=h.split(".");for(p=0;p<h.length-1;p++){var l=h[p];if(!(l in k))return;k=k[l]}h=h[h.length-1];p=k[h];n=n(p);n!=p&&null!=n&&$jscomp.defineProperty(k,h,{configurable:!0,writable:!0,value:n})};
$jscomp.polyfillIsolated=function(h,n,k,p){var l=h.split(".");h=1===l.length;p=l[0];p=!h&&p in $jscomp.polyfills?$jscomp.polyfills:$jscomp.global;for(var y=0;y<l.length-1;y++){var f=l[y];if(!(f in p))return;p=p[f]}l=l[l.length-1];k=$jscomp.IS_SYMBOL_NATIVE&&"es6"===k?p[l]:null;n=n(k);null!=n&&(h?$jscomp.defineProperty($jscomp.polyfills,l,{configurable:!0,writable:!0,value:n}):n!==k&&(void 0===$jscomp.propertyToPolyfillSymbol[l]&&(k=1E9*Math.random()>>>0,$jscomp.propertyToPolyfillSymbol[l]=$jscomp.IS_SYMBOL_NATIVE?
$jscomp.global.Symbol(l):$jscomp.POLYFILL_PREFIX+k+"$"+l),$jscomp.defineProperty(p,$jscomp.propertyToPolyfillSymbol[l],{configurable:!0,writable:!0,value:n})))};
$jscomp.polyfill("Promise",function(h){function n(){this.batch_=null}function k(f){return f instanceof l?f:new l(function(q,u){q(f)})}if(h&&(!($jscomp.FORCE_POLYFILL_PROMISE||$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION&&"undefined"===typeof $jscomp.global.PromiseRejectionEvent)||!$jscomp.global.Promise||-1===$jscomp.global.Promise.toString().indexOf("[native code]")))return h;n.prototype.asyncExecute=function(f){if(null==this.batch_){this.batch_=[];var q=this;this.asyncExecuteFunction(function(){q.executeBatch_()})}this.batch_.push(f)};
var p=$jscomp.global.setTimeout;n.prototype.asyncExecuteFunction=function(f){p(f,0)};n.prototype.executeBatch_=function(){for(;this.batch_&&this.batch_.length;){var f=this.batch_;this.batch_=[];for(var q=0;q<f.length;++q){var u=f[q];f[q]=null;try{u()}catch(A){this.asyncThrow_(A)}}}this.batch_=null};n.prototype.asyncThrow_=function(f){this.asyncExecuteFunction(function(){throw f;})};var l=function(f){this.state_=0;this.result_=void 0;this.onSettledCallbacks_=[];this.isRejectionHandled_=!1;var q=this.createResolveAndReject_();
try{f(q.resolve,q.reject)}catch(u){q.reject(u)}};l.prototype.createResolveAndReject_=function(){function f(A){return function(F){u||(u=!0,A.call(q,F))}}var q=this,u=!1;return{resolve:f(this.resolveTo_),reject:f(this.reject_)}};l.prototype.resolveTo_=function(f){if(f===this)this.reject_(new TypeError("A Promise cannot resolve to itself"));else if(f instanceof l)this.settleSameAsPromise_(f);else{a:switch(typeof f){case "object":var q=null!=f;break a;case "function":q=!0;break a;default:q=!1}q?this.resolveToNonPromiseObj_(f):
this.fulfill_(f)}};l.prototype.resolveToNonPromiseObj_=function(f){var q=void 0;try{q=f.then}catch(u){this.reject_(u);return}"function"==typeof q?this.settleSameAsThenable_(q,f):this.fulfill_(f)};l.prototype.reject_=function(f){this.settle_(2,f)};l.prototype.fulfill_=function(f){this.settle_(1,f)};l.prototype.settle_=function(f,q){if(0!=this.state_)throw Error("Cannot settle("+f+", "+q+"): Promise already settled in state"+this.state_);this.state_=f;this.result_=q;2===this.state_&&this.scheduleUnhandledRejectionCheck_();
this.executeOnSettledCallbacks_()};l.prototype.scheduleUnhandledRejectionCheck_=function(){var f=this;p(function(){if(f.notifyUnhandledRejection_()){var q=$jscomp.global.console;"undefined"!==typeof q&&q.error(f.result_)}},1)};l.prototype.notifyUnhandledRejection_=function(){if(this.isRejectionHandled_)return!1;var f=$jscomp.global.CustomEvent,q=$jscomp.global.Event,u=$jscomp.global.dispatchEvent;if("undefined"===typeof u)return!0;"function"===typeof f?f=new f("unhandledrejection",{cancelable:!0}):
"function"===typeof q?f=new q("unhandledrejection",{cancelable:!0}):(f=$jscomp.global.document.createEvent("CustomEvent"),f.initCustomEvent("unhandledrejection",!1,!0,f));f.promise=this;f.reason=this.result_;return u(f)};l.prototype.executeOnSettledCallbacks_=function(){if(null!=this.onSettledCallbacks_){for(var f=0;f<this.onSettledCallbacks_.length;++f)y.asyncExecute(this.onSettledCallbacks_[f]);this.onSettledCallbacks_=null}};var y=new n;l.prototype.settleSameAsPromise_=function(f){var q=this.createResolveAndReject_();
f.callWhenSettled_(q.resolve,q.reject)};l.prototype.settleSameAsThenable_=function(f,q){var u=this.createResolveAndReject_();try{f.call(q,u.resolve,u.reject)}catch(A){u.reject(A)}};l.prototype.then=function(f,q){function u(w,B){return"function"==typeof w?function(R){try{A(w(R))}catch(Z){F(Z)}}:B}var A,F,v=new l(function(w,B){A=w;F=B});this.callWhenSettled_(u(f,A),u(q,F));return v};l.prototype.catch=function(f){return this.then(void 0,f)};l.prototype.callWhenSettled_=function(f,q){function u(){switch(A.state_){case 1:f(A.result_);
break;case 2:q(A.result_);break;default:throw Error("Unexpected state: "+A.state_);}}var A=this;null==this.onSettledCallbacks_?y.asyncExecute(u):this.onSettledCallbacks_.push(u);this.isRejectionHandled_=!0};l.resolve=k;l.reject=function(f){return new l(function(q,u){u(f)})};l.race=function(f){return new l(function(q,u){for(var A=$jscomp.makeIterator(f),F=A.next();!F.done;F=A.next())k(F.value).callWhenSettled_(q,u)})};l.all=function(f){var q=$jscomp.makeIterator(f),u=q.next();return u.done?k([]):new l(function(A,
F){function v(R){return function(Z){w[R]=Z;B--;0==B&&A(w)}}var w=[],B=0;do w.push(void 0),B++,k(u.value).callWhenSettled_(v(w.length-1),F),u=q.next();while(!u.done)})};return l},"es6","es3");$jscomp.owns=function(h,n){return Object.prototype.hasOwnProperty.call(h,n)};$jscomp.assign=$jscomp.TRUST_ES6_POLYFILLS&&"function"==typeof Object.assign?Object.assign:function(h,n){for(var k=1;k<arguments.length;k++){var p=arguments[k];if(p)for(var l in p)$jscomp.owns(p,l)&&(h[l]=p[l])}return h};
$jscomp.polyfill("Object.assign",function(h){return h||$jscomp.assign},"es6","es3");$jscomp.checkStringArgs=function(h,n,k){if(null==h)throw new TypeError("The 'this' value for String.prototype."+k+" must not be null or undefined");if(n instanceof RegExp)throw new TypeError("First argument to String.prototype."+k+" must not be a regular expression");return h+""};
$jscomp.polyfill("String.prototype.startsWith",function(h){return h?h:function(n,k){var p=$jscomp.checkStringArgs(this,n,"startsWith");n+="";var l=p.length,y=n.length;k=Math.max(0,Math.min(k|0,p.length));for(var f=0;f<y&&k<l;)if(p[k++]!=n[f++])return!1;return f>=y}},"es6","es3");
$jscomp.polyfill("Array.prototype.copyWithin",function(h){function n(k){k=Number(k);return Infinity===k||-Infinity===k?k:k|0}return h?h:function(k,p,l){var y=this.length;k=n(k);p=n(p);l=void 0===l?y:n(l);k=0>k?Math.max(y+k,0):Math.min(k,y);p=0>p?Math.max(y+p,0):Math.min(p,y);l=0>l?Math.max(y+l,0):Math.min(l,y);if(k<p)for(;p<l;)p in this?this[k++]=this[p++]:(delete this[k++],p++);else for(l=Math.min(l,y+p-k),k+=l-p;l>p;)--l in this?this[--k]=this[l]:delete this[--k];return this}},"es6","es3");
$jscomp.typedArrayCopyWithin=function(h){return h?h:Array.prototype.copyWithin};$jscomp.polyfill("Int8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8ClampedArray.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
$jscomp.polyfill("Uint16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float64Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
var DracoDecoderModule=function(){var h="undefined"!==typeof document&&document.currentScript?document.currentScript.src:void 0;"undefined"!==typeof __filename&&(h=h||__filename);return function(n){function k(e){return a.locateFile?a.locateFile(e,U):U+e}function p(e,b){if(e){var c=ia;var d=e+b;for(b=e;c[b]&&!(b>=d);)++b;if(16<b-e&&c.buffer&&ra)c=ra.decode(c.subarray(e,b));else{for(d="";e<b;){var g=c[e++];if(g&128){var t=c[e++]&63;if(192==(g&224))d+=String.fromCharCode((g&31)<<6|t);else{var aa=c[e++]&
63;g=224==(g&240)?(g&15)<<12|t<<6|aa:(g&7)<<18|t<<12|aa<<6|c[e++]&63;65536>g?d+=String.fromCharCode(g):(g-=65536,d+=String.fromCharCode(55296|g>>10,56320|g&1023))}}else d+=String.fromCharCode(g)}c=d}}else c="";return c}function l(){var e=ja.buffer;a.HEAP8=W=new Int8Array(e);a.HEAP16=new Int16Array(e);a.HEAP32=ca=new Int32Array(e);a.HEAPU8=ia=new Uint8Array(e);a.HEAPU16=new Uint16Array(e);a.HEAPU32=Y=new Uint32Array(e);a.HEAPF32=new Float32Array(e);a.HEAPF64=new Float64Array(e)}function y(e){if(a.onAbort)a.onAbort(e);
e="Aborted("+e+")";da(e);sa=!0;e=new WebAssembly.RuntimeError(e+". Build with -sASSERTIONS for more info.");ka(e);throw e;}function f(e){try{if(e==P&&ea)return new Uint8Array(ea);if(ma)return ma(e);throw"both async and sync fetching of the wasm failed";}catch(b){y(b)}}function q(){if(!ea&&(ta||fa)){if("function"==typeof fetch&&!P.startsWith("file://"))return fetch(P,{credentials:"same-origin"}).then(function(e){if(!e.ok)throw"failed to load wasm binary file at '"+P+"'";return e.arrayBuffer()}).catch(function(){return f(P)});
if(na)return new Promise(function(e,b){na(P,function(c){e(new Uint8Array(c))},b)})}return Promise.resolve().then(function(){return f(P)})}function u(e){for(;0<e.length;)e.shift()(a)}function A(e){this.excPtr=e;this.ptr=e-24;this.set_type=function(b){Y[this.ptr+4>>2]=b};this.get_type=function(){return Y[this.ptr+4>>2]};this.set_destructor=function(b){Y[this.ptr+8>>2]=b};this.get_destructor=function(){return Y[this.ptr+8>>2]};this.set_refcount=function(b){ca[this.ptr>>2]=b};this.set_caught=function(b){W[this.ptr+
12>>0]=b?1:0};this.get_caught=function(){return 0!=W[this.ptr+12>>0]};this.set_rethrown=function(b){W[this.ptr+13>>0]=b?1:0};this.get_rethrown=function(){return 0!=W[this.ptr+13>>0]};this.init=function(b,c){this.set_adjusted_ptr(0);this.set_type(b);this.set_destructor(c);this.set_refcount(0);this.set_caught(!1);this.set_rethrown(!1)};this.add_ref=function(){ca[this.ptr>>2]+=1};this.release_ref=function(){var b=ca[this.ptr>>2];ca[this.ptr>>2]=b-1;return 1===b};this.set_adjusted_ptr=function(b){Y[this.ptr+
16>>2]=b};this.get_adjusted_ptr=function(){return Y[this.ptr+16>>2]};this.get_exception_ptr=function(){if(ua(this.get_type()))return Y[this.excPtr>>2];var b=this.get_adjusted_ptr();return 0!==b?b:this.excPtr}}function F(){function e(){if(!la&&(la=!0,a.calledRun=!0,!sa)){va=!0;u(oa);wa(a);if(a.onRuntimeInitialized)a.onRuntimeInitialized();if(a.postRun)for("function"==typeof a.postRun&&(a.postRun=[a.postRun]);a.postRun.length;)xa.unshift(a.postRun.shift());u(xa)}}if(!(0<ba)){if(a.preRun)for("function"==
typeof a.preRun&&(a.preRun=[a.preRun]);a.preRun.length;)ya.unshift(a.preRun.shift());u(ya);0<ba||(a.setStatus?(a.setStatus("Running..."),setTimeout(function(){setTimeout(function(){a.setStatus("")},1);e()},1)):e())}}function v(){}function w(e){return(e||v).__cache__}function B(e,b){var c=w(b),d=c[e];if(d)return d;d=Object.create((b||v).prototype);d.ptr=e;return c[e]=d}function R(e){if("string"===typeof e){for(var b=0,c=0;c<e.length;++c){var d=e.charCodeAt(c);127>=d?b++:2047>=d?b+=2:55296<=d&&57343>=
d?(b+=4,++c):b+=3}b=Array(b+1);c=0;d=b.length;if(0<d){d=c+d-1;for(var g=0;g<e.length;++g){var t=e.charCodeAt(g);if(55296<=t&&57343>=t){var aa=e.charCodeAt(++g);t=65536+((t&1023)<<10)|aa&1023}if(127>=t){if(c>=d)break;b[c++]=t}else{if(2047>=t){if(c+1>=d)break;b[c++]=192|t>>6}else{if(65535>=t){if(c+2>=d)break;b[c++]=224|t>>12}else{if(c+3>=d)break;b[c++]=240|t>>18;b[c++]=128|t>>12&63}b[c++]=128|t>>6&63}b[c++]=128|t&63}}b[c]=0}e=r.alloc(b,W);r.copy(b,W,e);return e}return e}function Z(e){if("object"===
typeof e){var b=r.alloc(e,W);r.copy(e,W,b);return b}return e}function X(){throw"cannot construct a VoidPtr, no constructor in IDL";}function S(){this.ptr=za();w(S)[this.ptr]=this}function Q(){this.ptr=Aa();w(Q)[this.ptr]=this}function V(){this.ptr=Ba();w(V)[this.ptr]=this}function x(){this.ptr=Ca();w(x)[this.ptr]=this}function D(){this.ptr=Da();w(D)[this.ptr]=this}function G(){this.ptr=Ea();w(G)[this.ptr]=this}function H(){this.ptr=Fa();w(H)[this.ptr]=this}function E(){this.ptr=Ga();w(E)[this.ptr]=
this}function T(){this.ptr=Ha();w(T)[this.ptr]=this}function C(){throw"cannot construct a Status, no constructor in IDL";}function I(){this.ptr=Ia();w(I)[this.ptr]=this}function J(){this.ptr=Ja();w(J)[this.ptr]=this}function K(){this.ptr=Ka();w(K)[this.ptr]=this}function L(){this.ptr=La();w(L)[this.ptr]=this}function M(){this.ptr=Ma();w(M)[this.ptr]=this}function N(){this.ptr=Na();w(N)[this.ptr]=this}function O(){this.ptr=Oa();w(O)[this.ptr]=this}function z(){this.ptr=Pa();w(z)[this.ptr]=this}function m(){this.ptr=
Qa();w(m)[this.ptr]=this}n=void 0===n?{}:n;var a="undefined"!=typeof n?n:{},wa,ka;a.ready=new Promise(function(e,b){wa=e;ka=b});var Ra=!1,Sa=!1;a.onRuntimeInitialized=function(){Ra=!0;if(Sa&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.onModuleParsed=function(){Sa=!0;if(Ra&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.isVersionSupported=function(e){if("string"!==typeof e)return!1;e=e.split(".");return 2>e.length||3<e.length?!1:1==e[0]&&0<=e[1]&&5>=e[1]?!0:0!=e[0]||10<
e[1]?!1:!0};var Ta=Object.assign({},a),ta="object"==typeof window,fa="function"==typeof importScripts,Ua="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,U="";if(Ua){var Va=require("fs"),pa=require("path");U=fa?pa.dirname(U)+"/":__dirname+"/";var Wa=function(e,b){e=e.startsWith("file://")?new URL(e):pa.normalize(e);return Va.readFileSync(e,b?void 0:"utf8")};var ma=function(e){e=Wa(e,!0);e.buffer||(e=new Uint8Array(e));return e};var na=function(e,
b,c){e=e.startsWith("file://")?new URL(e):pa.normalize(e);Va.readFile(e,function(d,g){d?c(d):b(g.buffer)})};1<process.argv.length&&process.argv[1].replace(/\\/g,"/");process.argv.slice(2);a.inspect=function(){return"[Emscripten Module object]"}}else if(ta||fa)fa?U=self.location.href:"undefined"!=typeof document&&document.currentScript&&(U=document.currentScript.src),h&&(U=h),U=0!==U.indexOf("blob:")?U.substr(0,U.replace(/[?#].*/,"").lastIndexOf("/")+1):"",Wa=function(e){var b=new XMLHttpRequest;b.open("GET",
e,!1);b.send(null);return b.responseText},fa&&(ma=function(e){var b=new XMLHttpRequest;b.open("GET",e,!1);b.responseType="arraybuffer";b.send(null);return new Uint8Array(b.response)}),na=function(e,b,c){var d=new XMLHttpRequest;d.open("GET",e,!0);d.responseType="arraybuffer";d.onload=function(){200==d.status||0==d.status&&d.response?b(d.response):c()};d.onerror=c;d.send(null)};a.print||console.log.bind(console);var da=a.printErr||console.warn.bind(console);Object.assign(a,Ta);Ta=null;var ea;a.wasmBinary&&
(ea=a.wasmBinary);"object"!=typeof WebAssembly&&y("no native wasm support detected");var ja,sa=!1,ra="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0,W,ia,ca,Y,ya=[],oa=[],xa=[],va=!1,ba=0,qa=null,ha=null;var P="draco_decoder_gltf.wasm";P.startsWith("data:application/octet-stream;base64,")||(P=k(P));var pd=0,qd={b:function(e,b,c){(new A(e)).init(b,c);pd++;throw e;},a:function(){y("")},d:function(e,b,c){ia.copyWithin(e,b,b+c)},c:function(e){var b=ia.length;e>>>=0;if(2147483648<e)return!1;
for(var c=1;4>=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,e+100663296);var g=Math;d=Math.max(e,d);g=g.min.call(g,2147483648,d+(65536-d%65536)%65536);a:{d=ja.buffer;try{ja.grow(g-d.byteLength+65535>>>16);l();var t=1;break a}catch(aa){}t=void 0}if(t)return!0}return!1}};(function(){function e(g,t){a.asm=g.exports;ja=a.asm.e;l();oa.unshift(a.asm.f);ba--;a.monitorRunDependencies&&a.monitorRunDependencies(ba);0==ba&&(null!==qa&&(clearInterval(qa),qa=null),ha&&(g=ha,ha=null,g()))}function b(g){e(g.instance)}
function c(g){return q().then(function(t){return WebAssembly.instantiate(t,d)}).then(function(t){return t}).then(g,function(t){da("failed to asynchronously prepare wasm: "+t);y(t)})}var d={a:qd};ba++;a.monitorRunDependencies&&a.monitorRunDependencies(ba);if(a.instantiateWasm)try{return a.instantiateWasm(d,e)}catch(g){da("Module.instantiateWasm callback failed with error: "+g),ka(g)}(function(){return ea||"function"!=typeof WebAssembly.instantiateStreaming||P.startsWith("data:application/octet-stream;base64,")||
P.startsWith("file://")||Ua||"function"!=typeof fetch?c(b):fetch(P,{credentials:"same-origin"}).then(function(g){return WebAssembly.instantiateStreaming(g,d).then(b,function(t){da("wasm streaming compile failed: "+t);da("falling back to ArrayBuffer instantiation");return c(b)})})})().catch(ka);return{}})();var Xa=a._emscripten_bind_VoidPtr___destroy___0=function(){return(Xa=a._emscripten_bind_VoidPtr___destroy___0=a.asm.h).apply(null,arguments)},za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=
function(){return(za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=a.asm.i).apply(null,arguments)},Ya=a._emscripten_bind_DecoderBuffer_Init_2=function(){return(Ya=a._emscripten_bind_DecoderBuffer_Init_2=a.asm.j).apply(null,arguments)},Za=a._emscripten_bind_DecoderBuffer___destroy___0=function(){return(Za=a._emscripten_bind_DecoderBuffer___destroy___0=a.asm.k).apply(null,arguments)},Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=function(){return(Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=
a.asm.l).apply(null,arguments)},$a=a._emscripten_bind_AttributeTransformData_transform_type_0=function(){return($a=a._emscripten_bind_AttributeTransformData_transform_type_0=a.asm.m).apply(null,arguments)},ab=a._emscripten_bind_AttributeTransformData___destroy___0=function(){return(ab=a._emscripten_bind_AttributeTransformData___destroy___0=a.asm.n).apply(null,arguments)},Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=function(){return(Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=
a.asm.o).apply(null,arguments)},bb=a._emscripten_bind_GeometryAttribute___destroy___0=function(){return(bb=a._emscripten_bind_GeometryAttribute___destroy___0=a.asm.p).apply(null,arguments)},Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=function(){return(Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=a.asm.q).apply(null,arguments)},cb=a._emscripten_bind_PointAttribute_size_0=function(){return(cb=a._emscripten_bind_PointAttribute_size_0=a.asm.r).apply(null,arguments)},db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=
function(){return(db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=a.asm.s).apply(null,arguments)},eb=a._emscripten_bind_PointAttribute_attribute_type_0=function(){return(eb=a._emscripten_bind_PointAttribute_attribute_type_0=a.asm.t).apply(null,arguments)},fb=a._emscripten_bind_PointAttribute_data_type_0=function(){return(fb=a._emscripten_bind_PointAttribute_data_type_0=a.asm.u).apply(null,arguments)},gb=a._emscripten_bind_PointAttribute_num_components_0=function(){return(gb=a._emscripten_bind_PointAttribute_num_components_0=
a.asm.v).apply(null,arguments)},hb=a._emscripten_bind_PointAttribute_normalized_0=function(){return(hb=a._emscripten_bind_PointAttribute_normalized_0=a.asm.w).apply(null,arguments)},ib=a._emscripten_bind_PointAttribute_byte_stride_0=function(){return(ib=a._emscripten_bind_PointAttribute_byte_stride_0=a.asm.x).apply(null,arguments)},jb=a._emscripten_bind_PointAttribute_byte_offset_0=function(){return(jb=a._emscripten_bind_PointAttribute_byte_offset_0=a.asm.y).apply(null,arguments)},kb=a._emscripten_bind_PointAttribute_unique_id_0=
function(){return(kb=a._emscripten_bind_PointAttribute_unique_id_0=a.asm.z).apply(null,arguments)},lb=a._emscripten_bind_PointAttribute___destroy___0=function(){return(lb=a._emscripten_bind_PointAttribute___destroy___0=a.asm.A).apply(null,arguments)},Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=function(){return(Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=a.asm.B).apply(null,arguments)},mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=
function(){return(mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=a.asm.C).apply(null,arguments)},nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=function(){return(nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=a.asm.D).apply(null,arguments)},ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=function(){return(ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=a.asm.E).apply(null,arguments)},pb=
a._emscripten_bind_AttributeQuantizationTransform_range_0=function(){return(pb=a._emscripten_bind_AttributeQuantizationTransform_range_0=a.asm.F).apply(null,arguments)},qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=function(){return(qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=a.asm.G).apply(null,arguments)},Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=function(){return(Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=
a.asm.H).apply(null,arguments)},rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=function(){return(rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=a.asm.I).apply(null,arguments)},sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=function(){return(sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=a.asm.J).apply(null,arguments)},tb=a._emscripten_bind_AttributeOctahedronTransform___destroy___0=function(){return(tb=
a._emscripten_bind_AttributeOctahedronTransform___destroy___0=a.asm.K).apply(null,arguments)},Fa=a._emscripten_bind_PointCloud_PointCloud_0=function(){return(Fa=a._emscripten_bind_PointCloud_PointCloud_0=a.asm.L).apply(null,arguments)},ub=a._emscripten_bind_PointCloud_num_attributes_0=function(){return(ub=a._emscripten_bind_PointCloud_num_attributes_0=a.asm.M).apply(null,arguments)},vb=a._emscripten_bind_PointCloud_num_points_0=function(){return(vb=a._emscripten_bind_PointCloud_num_points_0=a.asm.N).apply(null,
arguments)},wb=a._emscripten_bind_PointCloud___destroy___0=function(){return(wb=a._emscripten_bind_PointCloud___destroy___0=a.asm.O).apply(null,arguments)},Ga=a._emscripten_bind_Mesh_Mesh_0=function(){return(Ga=a._emscripten_bind_Mesh_Mesh_0=a.asm.P).apply(null,arguments)},xb=a._emscripten_bind_Mesh_num_faces_0=function(){return(xb=a._emscripten_bind_Mesh_num_faces_0=a.asm.Q).apply(null,arguments)},yb=a._emscripten_bind_Mesh_num_attributes_0=function(){return(yb=a._emscripten_bind_Mesh_num_attributes_0=
a.asm.R).apply(null,arguments)},zb=a._emscripten_bind_Mesh_num_points_0=function(){return(zb=a._emscripten_bind_Mesh_num_points_0=a.asm.S).apply(null,arguments)},Ab=a._emscripten_bind_Mesh___destroy___0=function(){return(Ab=a._emscripten_bind_Mesh___destroy___0=a.asm.T).apply(null,arguments)},Ha=a._emscripten_bind_Metadata_Metadata_0=function(){return(Ha=a._emscripten_bind_Metadata_Metadata_0=a.asm.U).apply(null,arguments)},Bb=a._emscripten_bind_Metadata___destroy___0=function(){return(Bb=a._emscripten_bind_Metadata___destroy___0=
a.asm.V).apply(null,arguments)},Cb=a._emscripten_bind_Status_code_0=function(){return(Cb=a._emscripten_bind_Status_code_0=a.asm.W).apply(null,arguments)},Db=a._emscripten_bind_Status_ok_0=function(){return(Db=a._emscripten_bind_Status_ok_0=a.asm.X).apply(null,arguments)},Eb=a._emscripten_bind_Status_error_msg_0=function(){return(Eb=a._emscripten_bind_Status_error_msg_0=a.asm.Y).apply(null,arguments)},Fb=a._emscripten_bind_Status___destroy___0=function(){return(Fb=a._emscripten_bind_Status___destroy___0=
a.asm.Z).apply(null,arguments)},Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=function(){return(Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=a.asm._).apply(null,arguments)},Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=function(){return(Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=a.asm.$).apply(null,arguments)},Hb=a._emscripten_bind_DracoFloat32Array_size_0=function(){return(Hb=a._emscripten_bind_DracoFloat32Array_size_0=a.asm.aa).apply(null,arguments)},Ib=
a._emscripten_bind_DracoFloat32Array___destroy___0=function(){return(Ib=a._emscripten_bind_DracoFloat32Array___destroy___0=a.asm.ba).apply(null,arguments)},Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=function(){return(Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=a.asm.ca).apply(null,arguments)},Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=function(){return(Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=a.asm.da).apply(null,arguments)},Kb=a._emscripten_bind_DracoInt8Array_size_0=
function(){return(Kb=a._emscripten_bind_DracoInt8Array_size_0=a.asm.ea).apply(null,arguments)},Lb=a._emscripten_bind_DracoInt8Array___destroy___0=function(){return(Lb=a._emscripten_bind_DracoInt8Array___destroy___0=a.asm.fa).apply(null,arguments)},Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=function(){return(Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=a.asm.ga).apply(null,arguments)},Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1=function(){return(Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1=
a.asm.ha).apply(null,arguments)},Nb=a._emscripten_bind_DracoUInt8Array_size_0=function(){return(Nb=a._emscripten_bind_DracoUInt8Array_size_0=a.asm.ia).apply(null,arguments)},Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=function(){return(Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=a.asm.ja).apply(null,arguments)},La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=function(){return(La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=a.asm.ka).apply(null,arguments)},Pb=a._emscripten_bind_DracoInt16Array_GetValue_1=
function(){return(Pb=a._emscripten_bind_DracoInt16Array_GetValue_1=a.asm.la).apply(null,arguments)},Qb=a._emscripten_bind_DracoInt16Array_size_0=function(){return(Qb=a._emscripten_bind_DracoInt16Array_size_0=a.asm.ma).apply(null,arguments)},Rb=a._emscripten_bind_DracoInt16Array___destroy___0=function(){return(Rb=a._emscripten_bind_DracoInt16Array___destroy___0=a.asm.na).apply(null,arguments)},Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=function(){return(Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=
a.asm.oa).apply(null,arguments)},Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=function(){return(Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=a.asm.pa).apply(null,arguments)},Tb=a._emscripten_bind_DracoUInt16Array_size_0=function(){return(Tb=a._emscripten_bind_DracoUInt16Array_size_0=a.asm.qa).apply(null,arguments)},Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=function(){return(Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=a.asm.ra).apply(null,arguments)},Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=
function(){return(Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=a.asm.sa).apply(null,arguments)},Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=function(){return(Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=a.asm.ta).apply(null,arguments)},Wb=a._emscripten_bind_DracoInt32Array_size_0=function(){return(Wb=a._emscripten_bind_DracoInt32Array_size_0=a.asm.ua).apply(null,arguments)},Xb=a._emscripten_bind_DracoInt32Array___destroy___0=function(){return(Xb=a._emscripten_bind_DracoInt32Array___destroy___0=
a.asm.va).apply(null,arguments)},Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=function(){return(Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=a.asm.wa).apply(null,arguments)},Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=function(){return(Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=a.asm.xa).apply(null,arguments)},Zb=a._emscripten_bind_DracoUInt32Array_size_0=function(){return(Zb=a._emscripten_bind_DracoUInt32Array_size_0=a.asm.ya).apply(null,arguments)},$b=a._emscripten_bind_DracoUInt32Array___destroy___0=
function(){return($b=a._emscripten_bind_DracoUInt32Array___destroy___0=a.asm.za).apply(null,arguments)},Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=function(){return(Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=a.asm.Aa).apply(null,arguments)},ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=function(){return(ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=a.asm.Ba).apply(null,arguments)},bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=function(){return(bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=
a.asm.Ca).apply(null,arguments)},cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=function(){return(cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=a.asm.Da).apply(null,arguments)},dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=function(){return(dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=a.asm.Ea).apply(null,arguments)},ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=function(){return(ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=a.asm.Fa).apply(null,
arguments)},fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=function(){return(fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=a.asm.Ga).apply(null,arguments)},gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=function(){return(gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=a.asm.Ha).apply(null,arguments)},hc=a._emscripten_bind_MetadataQuerier___destroy___0=function(){return(hc=a._emscripten_bind_MetadataQuerier___destroy___0=a.asm.Ia).apply(null,arguments)},Qa=a._emscripten_bind_Decoder_Decoder_0=
function(){return(Qa=a._emscripten_bind_Decoder_Decoder_0=a.asm.Ja).apply(null,arguments)},ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=function(){return(ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=a.asm.Ka).apply(null,arguments)},jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=function(){return(jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=a.asm.La).apply(null,arguments)},kc=a._emscripten_bind_Decoder_GetAttributeId_2=function(){return(kc=a._emscripten_bind_Decoder_GetAttributeId_2=
a.asm.Ma).apply(null,arguments)},lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=function(){return(lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=a.asm.Na).apply(null,arguments)},mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=function(){return(mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=a.asm.Oa).apply(null,arguments)},nc=a._emscripten_bind_Decoder_GetAttribute_2=function(){return(nc=a._emscripten_bind_Decoder_GetAttribute_2=a.asm.Pa).apply(null,arguments)},
oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=function(){return(oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=a.asm.Qa).apply(null,arguments)},pc=a._emscripten_bind_Decoder_GetMetadata_1=function(){return(pc=a._emscripten_bind_Decoder_GetMetadata_1=a.asm.Ra).apply(null,arguments)},qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=function(){return(qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=a.asm.Sa).apply(null,arguments)},rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=
function(){return(rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=a.asm.Ta).apply(null,arguments)},sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=function(){return(sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=a.asm.Ua).apply(null,arguments)},tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=function(){return(tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=a.asm.Va).apply(null,arguments)},uc=a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=function(){return(uc=
a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=a.asm.Wa).apply(null,arguments)},vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=function(){return(vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=a.asm.Xa).apply(null,arguments)},wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=function(){return(wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=a.asm.Ya).apply(null,arguments)},xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=function(){return(xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=
a.asm.Za).apply(null,arguments)},yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=function(){return(yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=a.asm._a).apply(null,arguments)},zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=function(){return(zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=a.asm.$a).apply(null,arguments)},Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=function(){return(Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=
a.asm.ab).apply(null,arguments)},Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=function(){return(Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=a.asm.bb).apply(null,arguments)},Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=function(){return(Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=a.asm.cb).apply(null,arguments)},Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=function(){return(Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=
a.asm.db).apply(null,arguments)},Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=function(){return(Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=a.asm.eb).apply(null,arguments)},Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=function(){return(Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=a.asm.fb).apply(null,arguments)},Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=function(){return(Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=
a.asm.gb).apply(null,arguments)},Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=function(){return(Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=a.asm.hb).apply(null,arguments)},Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=function(){return(Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=a.asm.ib).apply(null,arguments)},Jc=a._emscripten_bind_Decoder___destroy___0=function(){return(Jc=a._emscripten_bind_Decoder___destroy___0=a.asm.jb).apply(null,arguments)},Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=
function(){return(Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=a.asm.kb).apply(null,arguments)},Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=function(){return(Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=a.asm.lb).apply(null,arguments)},Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=function(){return(Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=
a.asm.mb).apply(null,arguments)},Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=function(){return(Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=a.asm.nb).apply(null,arguments)},Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=function(){return(Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=a.asm.ob).apply(null,arguments)},Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=function(){return(Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=
a.asm.pb).apply(null,arguments)},Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=function(){return(Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=a.asm.qb).apply(null,arguments)},Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=function(){return(Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=a.asm.rb).apply(null,arguments)},Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=function(){return(Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=
a.asm.sb).apply(null,arguments)},Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=function(){return(Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=a.asm.tb).apply(null,arguments)},Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=function(){return(Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=a.asm.ub).apply(null,arguments)},Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=function(){return(Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=
a.asm.vb).apply(null,arguments)},Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=function(){return(Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=a.asm.wb).apply(null,arguments)},Xc=a._emscripten_enum_draco_DataType_DT_INVALID=function(){return(Xc=a._emscripten_enum_draco_DataType_DT_INVALID=a.asm.xb).apply(null,arguments)},Yc=a._emscripten_enum_draco_DataType_DT_INT8=function(){return(Yc=a._emscripten_enum_draco_DataType_DT_INT8=a.asm.yb).apply(null,arguments)},Zc=
a._emscripten_enum_draco_DataType_DT_UINT8=function(){return(Zc=a._emscripten_enum_draco_DataType_DT_UINT8=a.asm.zb).apply(null,arguments)},$c=a._emscripten_enum_draco_DataType_DT_INT16=function(){return($c=a._emscripten_enum_draco_DataType_DT_INT16=a.asm.Ab).apply(null,arguments)},ad=a._emscripten_enum_draco_DataType_DT_UINT16=function(){return(ad=a._emscripten_enum_draco_DataType_DT_UINT16=a.asm.Bb).apply(null,arguments)},bd=a._emscripten_enum_draco_DataType_DT_INT32=function(){return(bd=a._emscripten_enum_draco_DataType_DT_INT32=
a.asm.Cb).apply(null,arguments)},cd=a._emscripten_enum_draco_DataType_DT_UINT32=function(){return(cd=a._emscripten_enum_draco_DataType_DT_UINT32=a.asm.Db).apply(null,arguments)},dd=a._emscripten_enum_draco_DataType_DT_INT64=function(){return(dd=a._emscripten_enum_draco_DataType_DT_INT64=a.asm.Eb).apply(null,arguments)},ed=a._emscripten_enum_draco_DataType_DT_UINT64=function(){return(ed=a._emscripten_enum_draco_DataType_DT_UINT64=a.asm.Fb).apply(null,arguments)},fd=a._emscripten_enum_draco_DataType_DT_FLOAT32=
function(){return(fd=a._emscripten_enum_draco_DataType_DT_FLOAT32=a.asm.Gb).apply(null,arguments)},gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=function(){return(gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=a.asm.Hb).apply(null,arguments)},hd=a._emscripten_enum_draco_DataType_DT_BOOL=function(){return(hd=a._emscripten_enum_draco_DataType_DT_BOOL=a.asm.Ib).apply(null,arguments)},id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=function(){return(id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=
a.asm.Jb).apply(null,arguments)},jd=a._emscripten_enum_draco_StatusCode_OK=function(){return(jd=a._emscripten_enum_draco_StatusCode_OK=a.asm.Kb).apply(null,arguments)},kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=function(){return(kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=a.asm.Lb).apply(null,arguments)},ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=function(){return(ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=a.asm.Mb).apply(null,arguments)},md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=
function(){return(md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=a.asm.Nb).apply(null,arguments)},nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=function(){return(nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=a.asm.Ob).apply(null,arguments)},od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=function(){return(od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=a.asm.Pb).apply(null,arguments)};a._malloc=function(){return(a._malloc=a.asm.Qb).apply(null,arguments)};
a._free=function(){return(a._free=a.asm.Rb).apply(null,arguments)};var ua=function(){return(ua=a.asm.Sb).apply(null,arguments)};a.___start_em_js=11660;a.___stop_em_js=11758;var la;ha=function b(){la||F();la||(ha=b)};if(a.preInit)for("function"==typeof a.preInit&&(a.preInit=[a.preInit]);0<a.preInit.length;)a.preInit.pop()();F();v.prototype=Object.create(v.prototype);v.prototype.constructor=v;v.prototype.__class__=v;v.__cache__={};a.WrapperObject=v;a.getCache=w;a.wrapPointer=B;a.castObject=function(b,
c){return B(b.ptr,c)};a.NULL=B(0);a.destroy=function(b){if(!b.__destroy__)throw"Error: Cannot destroy object. (Did you create it yourself?)";b.__destroy__();delete w(b.__class__)[b.ptr]};a.compare=function(b,c){return b.ptr===c.ptr};a.getPointer=function(b){return b.ptr};a.getClass=function(b){return b.__class__};var r={buffer:0,size:0,pos:0,temps:[],needed:0,prepare:function(){if(r.needed){for(var b=0;b<r.temps.length;b++)a._free(r.temps[b]);r.temps.length=0;a._free(r.buffer);r.buffer=0;r.size+=
r.needed;r.needed=0}r.buffer||(r.size+=128,r.buffer=a._malloc(r.size),r.buffer||y(void 0));r.pos=0},alloc:function(b,c){r.buffer||y(void 0);b=b.length*c.BYTES_PER_ELEMENT;b=b+7&-8;r.pos+b>=r.size?(0<b||y(void 0),r.needed+=b,c=a._malloc(b),r.temps.push(c)):(c=r.buffer+r.pos,r.pos+=b);return c},copy:function(b,c,d){d>>>=0;switch(c.BYTES_PER_ELEMENT){case 2:d>>>=1;break;case 4:d>>>=2;break;case 8:d>>>=3}for(var g=0;g<b.length;g++)c[d+g]=b[g]}};X.prototype=Object.create(v.prototype);X.prototype.constructor=
X;X.prototype.__class__=X;X.__cache__={};a.VoidPtr=X;X.prototype.__destroy__=X.prototype.__destroy__=function(){Xa(this.ptr)};S.prototype=Object.create(v.prototype);S.prototype.constructor=S;S.prototype.__class__=S;S.__cache__={};a.DecoderBuffer=S;S.prototype.Init=S.prototype.Init=function(b,c){var d=this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);Ya(d,b,c)};S.prototype.__destroy__=S.prototype.__destroy__=function(){Za(this.ptr)};Q.prototype=Object.create(v.prototype);
Q.prototype.constructor=Q;Q.prototype.__class__=Q;Q.__cache__={};a.AttributeTransformData=Q;Q.prototype.transform_type=Q.prototype.transform_type=function(){return $a(this.ptr)};Q.prototype.__destroy__=Q.prototype.__destroy__=function(){ab(this.ptr)};V.prototype=Object.create(v.prototype);V.prototype.constructor=V;V.prototype.__class__=V;V.__cache__={};a.GeometryAttribute=V;V.prototype.__destroy__=V.prototype.__destroy__=function(){bb(this.ptr)};x.prototype=Object.create(v.prototype);x.prototype.constructor=
x;x.prototype.__class__=x;x.__cache__={};a.PointAttribute=x;x.prototype.size=x.prototype.size=function(){return cb(this.ptr)};x.prototype.GetAttributeTransformData=x.prototype.GetAttributeTransformData=function(){return B(db(this.ptr),Q)};x.prototype.attribute_type=x.prototype.attribute_type=function(){return eb(this.ptr)};x.prototype.data_type=x.prototype.data_type=function(){return fb(this.ptr)};x.prototype.num_components=x.prototype.num_components=function(){return gb(this.ptr)};x.prototype.normalized=
x.prototype.normalized=function(){return!!hb(this.ptr)};x.prototype.byte_stride=x.prototype.byte_stride=function(){return ib(this.ptr)};x.prototype.byte_offset=x.prototype.byte_offset=function(){return jb(this.ptr)};x.prototype.unique_id=x.prototype.unique_id=function(){return kb(this.ptr)};x.prototype.__destroy__=x.prototype.__destroy__=function(){lb(this.ptr)};D.prototype=Object.create(v.prototype);D.prototype.constructor=D;D.prototype.__class__=D;D.__cache__={};a.AttributeQuantizationTransform=
D;D.prototype.InitFromAttribute=D.prototype.InitFromAttribute=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!mb(c,b)};D.prototype.quantization_bits=D.prototype.quantization_bits=function(){return nb(this.ptr)};D.prototype.min_value=D.prototype.min_value=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return ob(c,b)};D.prototype.range=D.prototype.range=function(){return pb(this.ptr)};D.prototype.__destroy__=D.prototype.__destroy__=function(){qb(this.ptr)};G.prototype=
Object.create(v.prototype);G.prototype.constructor=G;G.prototype.__class__=G;G.__cache__={};a.AttributeOctahedronTransform=G;G.prototype.InitFromAttribute=G.prototype.InitFromAttribute=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!rb(c,b)};G.prototype.quantization_bits=G.prototype.quantization_bits=function(){return sb(this.ptr)};G.prototype.__destroy__=G.prototype.__destroy__=function(){tb(this.ptr)};H.prototype=Object.create(v.prototype);H.prototype.constructor=H;H.prototype.__class__=
H;H.__cache__={};a.PointCloud=H;H.prototype.num_attributes=H.prototype.num_attributes=function(){return ub(this.ptr)};H.prototype.num_points=H.prototype.num_points=function(){return vb(this.ptr)};H.prototype.__destroy__=H.prototype.__destroy__=function(){wb(this.ptr)};E.prototype=Object.create(v.prototype);E.prototype.constructor=E;E.prototype.__class__=E;E.__cache__={};a.Mesh=E;E.prototype.num_faces=E.prototype.num_faces=function(){return xb(this.ptr)};E.prototype.num_attributes=E.prototype.num_attributes=
function(){return yb(this.ptr)};E.prototype.num_points=E.prototype.num_points=function(){return zb(this.ptr)};E.prototype.__destroy__=E.prototype.__destroy__=function(){Ab(this.ptr)};T.prototype=Object.create(v.prototype);T.prototype.constructor=T;T.prototype.__class__=T;T.__cache__={};a.Metadata=T;T.prototype.__destroy__=T.prototype.__destroy__=function(){Bb(this.ptr)};C.prototype=Object.create(v.prototype);C.prototype.constructor=C;C.prototype.__class__=C;C.__cache__={};a.Status=C;C.prototype.code=
C.prototype.code=function(){return Cb(this.ptr)};C.prototype.ok=C.prototype.ok=function(){return!!Db(this.ptr)};C.prototype.error_msg=C.prototype.error_msg=function(){return p(Eb(this.ptr))};C.prototype.__destroy__=C.prototype.__destroy__=function(){Fb(this.ptr)};I.prototype=Object.create(v.prototype);I.prototype.constructor=I;I.prototype.__class__=I;I.__cache__={};a.DracoFloat32Array=I;I.prototype.GetValue=I.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Gb(c,
b)};I.prototype.size=I.prototype.size=function(){return Hb(this.ptr)};I.prototype.__destroy__=I.prototype.__destroy__=function(){Ib(this.ptr)};J.prototype=Object.create(v.prototype);J.prototype.constructor=J;J.prototype.__class__=J;J.__cache__={};a.DracoInt8Array=J;J.prototype.GetValue=J.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Jb(c,b)};J.prototype.size=J.prototype.size=function(){return Kb(this.ptr)};J.prototype.__destroy__=J.prototype.__destroy__=function(){Lb(this.ptr)};
K.prototype=Object.create(v.prototype);K.prototype.constructor=K;K.prototype.__class__=K;K.__cache__={};a.DracoUInt8Array=K;K.prototype.GetValue=K.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Mb(c,b)};K.prototype.size=K.prototype.size=function(){return Nb(this.ptr)};K.prototype.__destroy__=K.prototype.__destroy__=function(){Ob(this.ptr)};L.prototype=Object.create(v.prototype);L.prototype.constructor=L;L.prototype.__class__=L;L.__cache__={};a.DracoInt16Array=
L;L.prototype.GetValue=L.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Pb(c,b)};L.prototype.size=L.prototype.size=function(){return Qb(this.ptr)};L.prototype.__destroy__=L.prototype.__destroy__=function(){Rb(this.ptr)};M.prototype=Object.create(v.prototype);M.prototype.constructor=M;M.prototype.__class__=M;M.__cache__={};a.DracoUInt16Array=M;M.prototype.GetValue=M.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Sb(c,b)};
M.prototype.size=M.prototype.size=function(){return Tb(this.ptr)};M.prototype.__destroy__=M.prototype.__destroy__=function(){Ub(this.ptr)};N.prototype=Object.create(v.prototype);N.prototype.constructor=N;N.prototype.__class__=N;N.__cache__={};a.DracoInt32Array=N;N.prototype.GetValue=N.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Vb(c,b)};N.prototype.size=N.prototype.size=function(){return Wb(this.ptr)};N.prototype.__destroy__=N.prototype.__destroy__=function(){Xb(this.ptr)};
O.prototype=Object.create(v.prototype);O.prototype.constructor=O;O.prototype.__class__=O;O.__cache__={};a.DracoUInt32Array=O;O.prototype.GetValue=O.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Yb(c,b)};O.prototype.size=O.prototype.size=function(){return Zb(this.ptr)};O.prototype.__destroy__=O.prototype.__destroy__=function(){$b(this.ptr)};z.prototype=Object.create(v.prototype);z.prototype.constructor=z;z.prototype.__class__=z;z.__cache__={};a.MetadataQuerier=
z;z.prototype.HasEntry=z.prototype.HasEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return!!ac(d,b,c)};z.prototype.GetIntEntry=z.prototype.GetIntEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return bc(d,b,c)};z.prototype.GetIntEntryArray=z.prototype.GetIntEntryArray=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===
typeof c?c.ptr:R(c);d&&"object"===typeof d&&(d=d.ptr);cc(g,b,c,d)};z.prototype.GetDoubleEntry=z.prototype.GetDoubleEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return dc(d,b,c)};z.prototype.GetStringEntry=z.prototype.GetStringEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return p(ec(d,b,c))};z.prototype.NumEntries=z.prototype.NumEntries=function(b){var c=this.ptr;
b&&"object"===typeof b&&(b=b.ptr);return fc(c,b)};z.prototype.GetEntryName=z.prototype.GetEntryName=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return p(gc(d,b,c))};z.prototype.__destroy__=z.prototype.__destroy__=function(){hc(this.ptr)};m.prototype=Object.create(v.prototype);m.prototype.constructor=m;m.prototype.__class__=m;m.__cache__={};a.Decoder=m;m.prototype.DecodeArrayToPointCloud=m.prototype.DecodeArrayToPointCloud=function(b,c,d){var g=
this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return B(ic(g,b,c,d),C)};m.prototype.DecodeArrayToMesh=m.prototype.DecodeArrayToMesh=function(b,c,d){var g=this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return B(jc(g,b,c,d),C)};m.prototype.GetAttributeId=m.prototype.GetAttributeId=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&
(c=c.ptr);return kc(d,b,c)};m.prototype.GetAttributeIdByName=m.prototype.GetAttributeIdByName=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return lc(d,b,c)};m.prototype.GetAttributeIdByMetadataEntry=m.prototype.GetAttributeIdByMetadataEntry=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);d=d&&"object"===typeof d?d.ptr:R(d);return mc(g,b,c,d)};m.prototype.GetAttribute=
m.prototype.GetAttribute=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(nc(d,b,c),x)};m.prototype.GetAttributeByUniqueId=m.prototype.GetAttributeByUniqueId=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(oc(d,b,c),x)};m.prototype.GetMetadata=m.prototype.GetMetadata=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return B(pc(c,b),T)};m.prototype.GetAttributeMetadata=m.prototype.GetAttributeMetadata=
function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(qc(d,b,c),T)};m.prototype.GetFaceFromMesh=m.prototype.GetFaceFromMesh=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!rc(g,b,c,d)};m.prototype.GetTriangleStripsFromMesh=m.prototype.GetTriangleStripsFromMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);
return sc(d,b,c)};m.prototype.GetTrianglesUInt16Array=m.prototype.GetTrianglesUInt16Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!tc(g,b,c,d)};m.prototype.GetTrianglesUInt32Array=m.prototype.GetTrianglesUInt32Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!uc(g,b,c,d)};m.prototype.GetAttributeFloat=m.prototype.GetAttributeFloat=
function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!vc(g,b,c,d)};m.prototype.GetAttributeFloatForAllPoints=m.prototype.GetAttributeFloatForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!wc(g,b,c,d)};m.prototype.GetAttributeIntForAllPoints=m.prototype.GetAttributeIntForAllPoints=function(b,c,d){var g=this.ptr;
b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!xc(g,b,c,d)};m.prototype.GetAttributeInt8ForAllPoints=m.prototype.GetAttributeInt8ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!yc(g,b,c,d)};m.prototype.GetAttributeUInt8ForAllPoints=m.prototype.GetAttributeUInt8ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=
b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!zc(g,b,c,d)};m.prototype.GetAttributeInt16ForAllPoints=m.prototype.GetAttributeInt16ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Ac(g,b,c,d)};m.prototype.GetAttributeUInt16ForAllPoints=m.prototype.GetAttributeUInt16ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&
(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Bc(g,b,c,d)};m.prototype.GetAttributeInt32ForAllPoints=m.prototype.GetAttributeInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Cc(g,b,c,d)};m.prototype.GetAttributeUInt32ForAllPoints=m.prototype.GetAttributeUInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===
typeof d&&(d=d.ptr);return!!Dc(g,b,c,d)};m.prototype.GetAttributeDataArrayForAllPoints=m.prototype.GetAttributeDataArrayForAllPoints=function(b,c,d,g,t){var aa=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);g&&"object"===typeof g&&(g=g.ptr);t&&"object"===typeof t&&(t=t.ptr);return!!Ec(aa,b,c,d,g,t)};m.prototype.SkipAttributeTransform=m.prototype.SkipAttributeTransform=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);Fc(c,
b)};m.prototype.GetEncodedGeometryType_Deprecated=m.prototype.GetEncodedGeometryType_Deprecated=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Gc(c,b)};m.prototype.DecodeBufferToPointCloud=m.prototype.DecodeBufferToPointCloud=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(Hc(d,b,c),C)};m.prototype.DecodeBufferToMesh=m.prototype.DecodeBufferToMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===
typeof c&&(c=c.ptr);return B(Ic(d,b,c),C)};m.prototype.__destroy__=m.prototype.__destroy__=function(){Jc(this.ptr)};(function(){function b(){a.ATTRIBUTE_INVALID_TRANSFORM=Kc();a.ATTRIBUTE_NO_TRANSFORM=Lc();a.ATTRIBUTE_QUANTIZATION_TRANSFORM=Mc();a.ATTRIBUTE_OCTAHEDRON_TRANSFORM=Nc();a.INVALID=Oc();a.POSITION=Pc();a.NORMAL=Qc();a.COLOR=Rc();a.TEX_COORD=Sc();a.GENERIC=Tc();a.INVALID_GEOMETRY_TYPE=Uc();a.POINT_CLOUD=Vc();a.TRIANGULAR_MESH=Wc();a.DT_INVALID=Xc();a.DT_INT8=Yc();a.DT_UINT8=Zc();a.DT_INT16=
$c();a.DT_UINT16=ad();a.DT_INT32=bd();a.DT_UINT32=cd();a.DT_INT64=dd();a.DT_UINT64=ed();a.DT_FLOAT32=fd();a.DT_FLOAT64=gd();a.DT_BOOL=hd();a.DT_TYPES_COUNT=id();a.OK=jd();a.DRACO_ERROR=kd();a.IO_ERROR=ld();a.INVALID_PARAMETER=md();a.UNSUPPORTED_VERSION=nd();a.UNKNOWN_VERSION=od()}va?b():oa.unshift(b)})();if("function"===typeof a.onModuleParsed)a.onModuleParsed();a.Decoder.prototype.GetEncodedGeometryType=function(b){if(b.__class__&&b.__class__===a.DecoderBuffer)return a.Decoder.prototype.GetEncodedGeometryType_Deprecated(b);
if(8>b.byteLength)return a.INVALID_GEOMETRY_TYPE;switch(b[7]){case 0:return a.POINT_CLOUD;case 1:return a.TRIANGULAR_MESH;default:return a.INVALID_GEOMETRY_TYPE}};return n.ready}}();"object"===typeof exports&&"object"===typeof module?module.exports=DracoDecoderModule:"function"===typeof define&&define.amd?define([],function(){return DracoDecoderModule}):"object"===typeof exports&&(exports.DracoDecoderModule=DracoDecoderModule);
@@ -0,0 +1,111 @@
import { createHash } from "node:crypto";
import { copyFileSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1");
const sha256File = (path) =>
createHash("sha256").update(readFileSync(path)).digest("hex");
if (manifest.version !== "0.34.1") {
throw new Error(
`Refusing to patch @rerun-io/web-viewer ${manifest.version}; audit the new vendor lifecycle first.`,
);
}
const vendorRuntime = [
{
label: "wasm runtime",
packagePath: resolve(packageRoot, "re_viewer_bg.wasm"),
vendorPath: resolve(vendorRoot, "re_viewer_bg.nodedc.wasm"),
publishedSha256: "3fe7aab8ea6bb0fd03c3ef694932943411ee174029e398c539c390beb0824d35",
previousNodedcSha256: "6aca9da47ad561d2046ad0c7c0b8a7f653f023a77132c5c6304789320db512c2",
nodedcSha256: "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469",
},
{
label: "wasm JavaScript glue",
packagePath: resolve(packageRoot, "re_viewer.js"),
vendorPath: resolve(vendorRoot, "re_viewer.nodedc.js"),
publishedSha256: "7c4ba900820137a6ba23e0f7f57d56e3b007db0de3825d5c8bf0d7684b3cc6a6",
nodedcSha256: "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339",
},
];
for (const runtime of vendorRuntime) {
const vendorSha256 = sha256File(runtime.vendorPath);
if (vendorSha256 !== runtime.nodedcSha256) {
throw new Error(
`Refusing corrupt NODE.DC Rerun ${runtime.label}: ${vendorSha256} != ${runtime.nodedcSha256}`,
);
}
const installedSha256 = sha256File(runtime.packagePath);
if (
![
runtime.publishedSha256,
runtime.previousNodedcSha256,
runtime.nodedcSha256,
].includes(installedSha256)
) {
throw new Error(
`Refusing to replace unexpected Rerun ${runtime.label}: ${installedSha256}`,
);
}
copyFileSync(runtime.vendorPath, runtime.packagePath);
if (sha256File(runtime.packagePath) !== runtime.nodedcSha256) {
throw new Error(`NODE.DC Rerun ${runtime.label} verification failed after copy`);
}
}
const patches = [
{
label: "compiled watchdog",
path: resolve(packageRoot, "index.js"),
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
},
{
label: "source watchdog",
path: resolve(packageRoot, "index.ts"),
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
},
{
label: "compiled singleton global listener",
path: resolve(packageRoot, "index.js"),
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
},
{
label: "source singleton global listener",
path: resolve(packageRoot, "index.ts"),
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
},
];
for (const patch of patches) {
const source = readFileSync(patch.path, "utf8");
if (source.includes(patch.after)) continue;
if (!source.includes(patch.before)) {
throw new Error(`Vendor ${patch.label} patch no longer matches ${patch.path}.`);
}
writeFileSync(patch.path, source.replace(patch.before, patch.after));
}
for (const path of [resolve(packageRoot, "index.js"), resolve(packageRoot, "index.ts")]) {
const source = readFileSync(path, "utf8");
const keyupListenerCount = source.split('window.addEventListener("keyup"').length - 1;
if (!source.includes("let globalEventListenersInstalled = false;") || keyupListenerCount !== 1) {
throw new Error(`Vendor singleton global listener verification failed for ${path}.`);
}
}
console.log(
"Patched @rerun-io/web-viewer 0.34.1 pointer navigation, camera continuity, watchdog teardown, and singleton global keyup listener.",
);
@@ -0,0 +1,128 @@
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const [inputArgument, outputArgument] = process.argv.slice(2);
if (!inputArgument || !outputArgument) {
throw new Error("Usage: node transform-rerun-web-viewer-glue.mjs INPUT OUTPUT");
}
const inputPath = resolve(inputArgument);
const outputPath = resolve(outputArgument);
const expectedGeneratedSha256 =
"cc196a93c5be972c801d46be4dc9934f7f042eb62941f0aa0678f1c8416c6874";
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
let code = readFileSync(inputPath, "utf8");
if (sha256(code) !== expectedGeneratedSha256) {
throw new Error(`Refusing to transform unexpected wasm-bindgen output: ${inputPath}`);
}
// This is the same no-modules-base transformation used by Rerun 0.34.1's
// rerun_js/web-viewer/build-wasm.mjs. Each factory call gets isolated closure state.
const wrapperStart = "let wasm_bindgen = (function(exports) {";
const wrapperEnd = `return Object.assign(__wbg_init, { initSync }, exports);
})({ __proto__: null });`;
if (!code.includes(wrapperStart) || !code.includes(wrapperEnd)) {
throw new Error("Rerun wasm-bindgen wrapper markers no longer match");
}
code = code.replace(wrapperStart, "").replace(wrapperEnd, "");
code = `
export default function() {
const exports = { __proto__: null };
${code}
function deinit() {
__wbg_init.__wbindgen_wasm_module = null;
wasmModule = null;
wasm = null;
cachedDataViewMemory0 = null;
cachedFloat32ArrayMemory0 = null;
cachedInt16ArrayMemory0 = null;
cachedInt32ArrayMemory0 = null;
cachedInt8ArrayMemory0 = null;
cachedUint16ArrayMemory0 = null;
cachedUint32ArrayMemory0 = null;
cachedUint8ArrayMemory0 = null;
}
return Object.assign(__wbg_init, { initSync, deinit }, exports);
}
`;
const closureDtorsOriginal = `const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(state => wasm.__wbindgen_destroy_closure(state.a, state.b));`;
const closureDtorsPatch = `const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(state => {
if (wasm) wasm.__wbindgen_destroy_closure(state.a, state.b);
});`;
if (!code.includes(closureDtorsOriginal)) {
throw new Error("Rerun CLOSURE_DTORS block no longer matches");
}
code = code.replace(closureDtorsOriginal, closureDtorsPatch);
const makeMutClosureOriginal = `function makeMutClosure(arg0, arg1, f) {
const state = { a: arg0, b: arg1, cnt: 1 };
const real = (...args) => {
// First up with a closure we increment the internal reference
// count. This ensures that the Rust closure environment won't
// be deallocated while we're invoking it.
state.cnt++;
const a = state.a;
state.a = 0;
try {
return f(a, state.b, ...args);
} finally {
state.a = a;
real._wbg_cb_unref();
}
};
real._wbg_cb_unref = () => {
if (--state.cnt === 0) {
wasm.__wbindgen_destroy_closure(state.a, state.b);
state.a = 0;
CLOSURE_DTORS.unregister(state);
}
};
CLOSURE_DTORS.register(real, state, state);
return real;
}`;
const makeMutClosurePatch = `function makeMutClosure(arg0, arg1, f) {
const state = { a: arg0, b: arg1, cnt: 1 };
const real = (...args) => {
state.cnt++;
const a = state.a;
state.a = 0;
try {
if (!wasm) return;
return f(a, state.b, ...args);
} finally {
state.a = a;
real._wbg_cb_unref();
}
};
real._wbg_cb_unref = () => {
if (--state.cnt === 0) {
if (wasm) wasm.__wbindgen_destroy_closure(state.a, state.b);
state.a = 0;
CLOSURE_DTORS.unregister(state);
}
};
CLOSURE_DTORS.register(real, state, state);
return real;
}`;
if (!code.includes(makeMutClosureOriginal)) {
throw new Error("Rerun makeMutClosure block no longer matches");
}
code = code.replace(makeMutClosureOriginal, makeMutClosurePatch);
writeFileSync(outputPath, code);
console.log(`Transformed Rerun wasm glue: ${sha256(code)} ${outputPath}`);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
import { useEffect, useMemo, useState } from "react";
import type {
EnvironmentBackground,
EnvironmentMediaItem,
} from "../core/environment/environmentSettings";
type ReadyEnvironmentMediaItem = EnvironmentMediaItem & {
url: string;
mediaKind: "image" | "video";
};
function isReadyMediaItem(
item: EnvironmentMediaItem,
): item is ReadyEnvironmentMediaItem {
return Boolean(item.url && item.mediaKind);
}
export function EnvironmentBackgroundMedia({
background,
}: {
background: EnvironmentBackground;
}) {
const items = useMemo(
() => background.items.filter(isReadyMediaItem),
[background.items],
);
const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|");
const [activeIndex, setActiveIndex] = useState(0);
const [failedIds, setFailedIds] = useState<Set<string>>(new Set());
const playableItems = items.filter((item) => !failedIds.has(item.id));
const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null;
useEffect(() => {
setActiveIndex(0);
setFailedIds(new Set());
}, [playlistIdentity]);
useEffect(() => {
if (
!background.enabled
|| !activeItem
|| activeItem.mediaKind !== "image"
|| playableItems.length < 2
) {
return;
}
const timer = window.setTimeout(() => {
setActiveIndex((current) => (current + 1) % playableItems.length);
}, background.imageDurationSeconds * 1_000);
return () => window.clearTimeout(timer);
}, [
activeItem,
background.enabled,
background.imageDurationSeconds,
playableItems.length,
]);
if (!background.enabled || !activeItem) return null;
return (
<div className="landing-stage__media" aria-hidden="true">
{activeItem.mediaKind === "video" ? (
<video
key={activeItem.url}
src={activeItem.url}
autoPlay
muted
loop={playableItems.length === 1}
playsInline
onEnded={() => setActiveIndex((current) => (
(current + 1) % playableItems.length
))}
onError={() => {
setFailedIds((current) => new Set(current).add(activeItem.id));
setActiveIndex(0);
}}
/>
) : (
<img
key={activeItem.url}
src={activeItem.url}
alt=""
onError={() => {
setFailedIds((current) => new Set(current).add(activeItem.id));
setActiveIndex(0);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,248 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Icon,
IconButton,
MediaSourceField,
RangeControl,
SortableList,
} from "@nodedc/ui-react";
import {
appendEnvironmentMediaItem,
inferEnvironmentMediaKind,
maxEnvironmentMediaItems,
removeEnvironmentMediaItem,
type EnvironmentBackground,
type EnvironmentMediaItem,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings";
interface EnvironmentMediaPlaylistEditorProps {
surfaceId: EnvironmentSurfaceId;
background: EnvironmentBackground;
disabled: boolean;
error: string | null;
onChange: (background: EnvironmentBackground) => void;
onBusyChange: (busy: boolean) => void;
onUpload: (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
const acceptedEnvironmentMedia = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
"video/mp4",
"video/webm",
"video/quicktime",
".png",
".jpg",
".jpeg",
".gif",
".webp",
".avif",
".mp4",
".webm",
".mov",
].join(",");
function patchItem(
background: EnvironmentBackground,
itemId: string,
patch: Partial<EnvironmentMediaItem>,
): EnvironmentBackground {
return {
...background,
items: background.items.map((item) => (
item.id === itemId ? { ...item, ...patch } : item
)),
};
}
export function EnvironmentMediaPlaylistEditor({
surfaceId,
background,
disabled,
error,
onChange,
onBusyChange,
onUpload,
}: EnvironmentMediaPlaylistEditorProps) {
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
const backgroundRef = useRef(background);
backgroundRef.current = background;
const displayedItems = useMemo(
() => [...background.items].reverse(),
[background.items],
);
useEffect(() => {
onBusyChange(uploadingIds.size > 0);
}, [onBusyChange, uploadingIds.size]);
useEffect(() => () => onBusyChange(false), [onBusyChange]);
const setItemError = (itemId: string, message?: string) => {
setItemErrors((current) => {
const next = { ...current };
if (message) next[itemId] = message;
else delete next[itemId];
return next;
});
};
const uploadFile = async (itemId: string, file?: File) => {
if (!file) return;
setUploadingIds((current) => new Set(current).add(itemId));
setItemError(itemId);
try {
const uploaded = await onUpload(surfaceId, itemId, file);
onChange(patchItem(backgroundRef.current, itemId, {
source: "file",
url: uploaded.url,
mediaKind: uploaded.mediaKind,
fileName: uploaded.fileName,
}));
} catch (reason) {
setItemError(
itemId,
reason instanceof Error
? reason.message
: "Не удалось загрузить медиаконтент.",
);
} finally {
setUploadingIds((current) => {
const next = new Set(current);
next.delete(itemId);
return next;
});
}
};
return (
<div className="environment-media-playlist">
<div className="environment-media-playlist__head">
<div>
<span>Видео / картинка</span>
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
</div>
<IconButton
label="Добавить медиаконтент"
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
onClick={() => onChange(appendEnvironmentMediaItem(background))}
>
<Icon name="plus" />
</IconButton>
</div>
{displayedItems.length ? (
<SortableList
items={displayedItems}
getId={(item) => item.id}
className="environment-media-playlist__items"
onReorder={(items) => onChange({
...background,
items: [...items].reverse(),
})}
>
{(item, { handle }) => {
const playbackIndex = background.items.findIndex(
(candidate) => candidate.id === item.id,
);
return (
<div className="environment-media-playlist__item">
<MediaSourceField
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
kindLabel={item.mediaKind ?? "media"}
source={item.source}
url={item.url ?? ""}
fileName={item.fileName}
uploading={uploadingIds.has(item.id)}
previewSrc={item.url}
previewKind={item.mediaKind}
accept={acceptedEnvironmentMedia}
path={`${surfaceId}.background.items[${playbackIndex}] → server environment media`}
hint="Файл сохраняется в Mission Core data root. URL должен вести прямо на media по HTTP(S)."
error={itemErrors[item.id] ?? (
playbackIndex === background.items.length - 1 ? error : null
)}
onSourceChange={(source) => {
if (source === item.source) return;
setItemError(item.id);
onChange(patchItem(background, item.id, {
source,
url: null,
mediaKind: null,
fileName: null,
}));
}}
onUrlChange={(url) => {
setItemError(item.id);
onChange(patchItem(background, item.id, {
source: "url",
url: url || null,
mediaKind: url ? inferEnvironmentMediaKind(url) : null,
fileName: null,
}));
}}
onFileChange={(file) => void uploadFile(item.id, file)}
/>
<div className="environment-media-playlist__item-actions">
<IconButton
label={`Удалить медиаконтент ${playbackIndex + 1}`}
disabled={disabled || uploadingIds.has(item.id)}
onClick={() => {
setItemError(item.id);
onChange(removeEnvironmentMediaItem(background, item.id));
}}
>
<Icon name="trash" />
</IconButton>
{handle}
</div>
</div>
);
}}
</SortableList>
) : (
<>
<p className="environment-media-playlist__empty">
Добавьте первый файл или прямую ссылку на медиаконтент.
</p>
{error ? (
<p className="environment-media-playlist__error" role="alert">
{error}
</p>
) : null}
</>
)}
<div className="environment-media-playlist__timing">
<RangeControl
label="Показывать изображение"
value={background.imageDurationSeconds}
min={1}
max={60}
step={1}
disabled={disabled}
formatValue={(value) => `${value} с`}
onChange={(imageDurationSeconds) => onChange({
...background,
imageDurationSeconds,
})}
/>
<span>
Новые элементы появляются сверху. Воспроизведение начинается снизу;
перетаскивание меняет порядок.
</span>
</div>
</div>
);
}
@@ -0,0 +1,358 @@
import { useEffect, useMemo, useState } from "react";
import {
Button,
FeatureSettingsWindow,
Select,
SettingsCard,
Switch,
TextAreaField,
TextField,
WindowFooterActions,
} from "@nodedc/ui-react";
import {
cloneEnvironmentSettings,
type EnvironmentBackground,
type EnvironmentPage,
type EnvironmentSettings,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "../core/environment/environmentSettings";
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor";
import {
roots,
workspaces,
workspacesForRoot,
} from "../productModel";
interface EnvironmentSettingsWindowProps {
open: boolean;
settings: EnvironmentSettings;
state: "loading" | "ready" | "saving" | "error";
error: string | null;
onClose: () => void;
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
onUpload: (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
function patchPage(
draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId,
patch: Partial<EnvironmentPage>,
): EnvironmentSettings {
return {
...draft,
pages: {
...draft.pages,
[surfaceId]: {
...draft.pages[surfaceId],
...patch,
},
},
};
}
function patchBackground(
draft: EnvironmentSettings,
surfaceId: EnvironmentSurfaceId,
patch: Partial<EnvironmentBackground>,
): EnvironmentSettings {
const page = draft.pages[surfaceId];
return patchPage(draft, surfaceId, {
background: {
...page.background,
...patch,
},
});
}
export function EnvironmentSettingsWindow({
open,
settings,
state,
error,
onClose,
onSave,
onUpload,
}: EnvironmentSettingsWindowProps) {
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("fleet");
const [uploading, setUploading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setDraft(cloneEnvironmentSettings(settings));
setLocalError(null);
}, [open, settings]);
const selectedPage = draft.pages[surfaceId];
const selectedBackground = selectedPage.background;
const pageOptions = useMemo(() => [
{
value: "home" as const,
label: draft.pages.home.headerLabel,
description: "Главная страница продукта",
},
...roots.map((root) => ({
value: root.id,
label: draft.pages[root.id].headerLabel,
description: `Стартовая страница раздела «${draft.pages[root.id].title}»`,
})),
], [draft.pages]);
const quickActionWorkspaces = useMemo(
() => surfaceId === "home"
? workspaces.filter((workspace) => !workspace.internalOnly)
: workspacesForRoot(surfaceId),
[surfaceId],
);
const quickActionOptions = useMemo(() => [
{
value: "none",
label: "Не показывать",
description: "Кнопка скрыта на стартовом экране",
},
...quickActionWorkspaces.map((workspace) => ({
value: workspace.id,
label: workspace.label,
description: workspace.description,
})),
], [quickActionWorkspaces]);
const dirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(settings),
[draft, settings],
);
const busy = state === "saving" || uploading;
const updatePage = (patch: Partial<EnvironmentPage>) => {
setDraft((current) => patchPage(current, surfaceId, patch));
};
const save = async () => {
const invalid = Object.entries(draft.pages).find(([, page]) => (
!page.headerLabel.trim()
|| !page.eyebrow.trim()
|| !page.title.trim()
|| !page.description.trim()
));
if (invalid) {
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
return;
}
if (
selectedPage.primaryWorkspaceId
&& selectedPage.primaryWorkspaceId === selectedPage.secondaryWorkspaceId
) {
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
return;
}
const invalidMedia = Object.entries(draft.pages).find(([, page]) => (
(page.background.enabled && !page.background.items.length)
|| page.background.items.some((item) => {
if (!item.url || !item.mediaKind) return true;
if (item.source !== "url") return false;
try {
const parsed = new URL(item.url);
return !["http:", "https:"].includes(parsed.protocol);
} catch {
return true;
}
})
));
if (invalidMedia) {
setSurfaceId(invalidMedia[0] as EnvironmentSurfaceId);
setLocalError(
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
);
return;
}
setLocalError(null);
try {
await onSave({
...draft,
pages: Object.fromEntries(
Object.entries(draft.pages).map(([id, page]) => [id, {
...page,
headerLabel: page.headerLabel.trim(),
eyebrow: page.eyebrow.trim(),
title: page.title.trim(),
description: page.description.trim(),
}]),
) as EnvironmentSettings["pages"],
});
onClose();
} catch (reason) {
setLocalError(reason instanceof Error
? reason.message
: "Не удалось сохранить настройки окружения.");
}
};
return (
<FeatureSettingsWindow
open={open}
title="Настройки Mission Core"
subtitle="Локальное операторское окружение"
identity={{
title: "DC",
subtitle: "Mission Core",
avatarLabel: "DC",
}}
sections={[
{
id: "environment",
label: "Окружение",
group: "MISSION CORE",
icon: "settings",
},
]}
activeSection="environment"
onSectionChange={() => undefined}
onClose={onClose}
footer={(
<WindowFooterActions>
<Button
variant="secondary"
disabled={!dirty || busy}
onClick={() => {
setDraft(cloneEnvironmentSettings(settings));
setLocalError(null);
}}
>
Сбросить изменения
</Button>
<Button
variant="primary"
disabled={!dirty || busy}
onClick={() => void save()}
>
{state === "saving" ? "Сохраняем…" : "Сохранить"}
</Button>
</WindowFooterActions>
)}
>
<div className="environment-settings">
<SettingsCard
eyebrow="ОКРУЖЕНИЕ"
title="Основные элементы управления"
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
actions={(
<Switch
checked={selectedBackground.enabled}
label="Показывать фон"
onChange={(enabled) => {
if (enabled && !selectedBackground.items.length) {
setLocalError("Сначала добавьте медиаконтент.");
return;
}
setLocalError(null);
setDraft((current) =>
patchBackground(current, surfaceId, { enabled }));
}}
/>
)}
>
<div className="environment-settings__editor">
<div className="environment-settings__surface">
<span>Страница</span>
<Select
label="Выбрать страницу окружения"
value={surfaceId}
options={pageOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => {
setSurfaceId(value);
setLocalError(null);
}}
/>
</div>
<div className="environment-settings__copy">
<TextField
label={surfaceId === "home" ? "Название продукта" : "Название в шапке"}
value={selectedPage.headerLabel}
maxLength={40}
onChange={(event) => updatePage({
headerLabel: event.currentTarget.value,
})}
/>
<TextField
label="Надзаголовок"
value={selectedPage.eyebrow}
maxLength={80}
onChange={(event) => updatePage({
eyebrow: event.currentTarget.value,
})}
/>
<TextField
label="Основной заголовок"
value={selectedPage.title}
maxLength={120}
onChange={(event) => updatePage({
title: event.currentTarget.value,
})}
/>
<TextAreaField
label="Описание"
value={selectedPage.description}
maxLength={500}
rows={3}
onChange={(event) => updatePage({
description: event.currentTarget.value,
})}
/>
</div>
<div className="environment-settings__quick-actions">
<div>
<span>Кнопка 1</span>
<Select
label="Выбрать первую быструю кнопку"
value={selectedPage.primaryWorkspaceId ?? "none"}
options={quickActionOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => updatePage({
primaryWorkspaceId: value === "none" ? null : value,
})}
/>
</div>
<div>
<span>Кнопка 2</span>
<Select
label="Выбрать вторую быструю кнопку"
value={selectedPage.secondaryWorkspaceId ?? "none"}
options={quickActionOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => updatePage({
secondaryWorkspaceId: value === "none" ? null : value,
})}
/>
</div>
</div>
<EnvironmentMediaPlaylistEditor
surfaceId={surfaceId}
background={selectedBackground}
disabled={busy}
error={localError ?? error}
onBusyChange={setUploading}
onChange={(background) => {
setLocalError(null);
setDraft((current) =>
patchBackground(current, surfaceId, background));
}}
onUpload={onUpload}
/>
</div>
</SettingsCard>
</div>
</FeatureSettingsWindow>
);
}
@@ -0,0 +1,187 @@
import { useLayoutEffect, useState, type RefObject } from "react";
import { WorkspaceWindow } from "@nodedc/ui-react";
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
import type { RecordedObservationPlayback } from "./RecordedFmp4Player";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
const WINDOW_WIDTH = 336;
const WINDOW_HEIGHT = 210;
const WINDOW_GAP = 16;
const WINDOW_INSET = 18;
const TIMELINE_CLEARANCE = 64;
type ClosestTarget = { closest: (selectors: string) => unknown };
export function shouldCaptureWorkspacePointer(
button: number,
target: ClosestTarget | null,
): boolean {
if (button !== 0 || !target) return false;
if (target.closest(".nodedc-workspace-window__resize")) return true;
if (!target.closest(".nodedc-workspace-window__head")) return false;
return !target.closest("button, input, select, textarea, a");
}
export function initialObservationWindowRect(
index: number,
count = 1,
bounds: { width: number; height: number } = { width: 1280, height: 720 },
): ObservationWindowRect {
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
const maxColumns = Math.max(
1,
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
);
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
const rowHeight = Math.max(
1,
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
);
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
const row = Math.floor(index / columns);
const column = index % columns;
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
return {
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
y: Math.max(
0,
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
),
width,
height,
};
}
export function FloatingObservationWindow({
source,
index,
count,
boundsRef,
rect,
maximized,
active,
hidden = false,
onRectChange,
onMaximizedChange,
onActivate,
onClose,
playback,
prepareRecorded,
recordedSessionGate,
recordedAdmissionKey,
onRecordedAdmissionChange,
}: {
source: ObservationSourceDescriptor;
index: number;
count: number;
boundsRef: RefObject<HTMLElement | null>;
rect?: ObservationWindowRect;
maximized: boolean;
active: boolean;
hidden?: boolean;
onRectChange: (rect: ObservationWindowRect) => void;
onMaximizedChange: (maximized: boolean) => void;
onActivate: () => void;
onClose: () => void;
playback?: RecordedObservationPlayback | null;
prepareRecorded?: boolean;
recordedSessionGate?: RecordedAdmissionPhase;
recordedAdmissionKey?: string | null;
onRecordedAdmissionChange?: (
sourceId: string,
state: RecordedCameraAdmissionState,
) => void;
}) {
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
useLayoutEffect(() => {
const element = boundsRef.current;
if (!element) return;
const measure = () => {
const next = { width: element.clientWidth, height: element.clientHeight };
setBounds((current) => (
current?.width === next.width && current.height === next.height ? current : next
));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [boundsRef]);
// Camera media admission must not wait for the viewport observer. A source
// can arrive in the same commit as a workspace transition; mounting it with
// the canonical fallback geometry keeps the browser transport alive until
// the real bounds are measured immediately afterward.
const initialRect = initialObservationWindowRect(index, count, bounds ?? undefined);
const windowRect = rect ?? initialRect;
return (
<WorkspaceWindow
boundsRef={boundsRef}
rect={windowRect}
onRectChange={onRectChange}
maximized={maximized}
onMaximizedChange={onMaximizedChange}
onActivate={onActivate}
onClose={onClose}
title={source.label}
subtitle={source.description}
status={(
<span className="floating-observation-window__status">
<i data-availability={source.availability} aria-hidden="true" />
{observationSourceStatusLabel(source)}
</span>
)}
footer={(
<span className="floating-observation-window__footer">
<span>{source.endpointLabel || source.transport}</span>
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
</span>
)}
minWidth={Math.min(280, windowRect.width)}
minHeight={Math.min(190, windowRect.height)}
resizable={source.capabilities.resizable}
active={active}
zIndex={maximized ? 15 : active ? 9 : 7}
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
onPointerDownCapture={(event) => {
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Pointer capture can fail if the browser ended the pointer between
// dispatch and capture. The donor's window listeners remain fallback.
}
}}
closeLabel={`Закрыть ${source.label}`}
maximizeLabel={`Развернуть ${source.label}`}
restoreLabel={`Восстановить ${source.label}`}
moveLabel={`Переместить ${source.label}`}
resizeLabel={`Изменить размер ${source.label}`}
>
<ObservationMedia
source={source}
playback={playback}
prepareRecorded={prepareRecorded}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedAdmissionKey}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
</WorkspaceWindow>
);
}
@@ -0,0 +1,78 @@
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
import type { EnvironmentPage } from "../core/environment/environmentSettings";
import type { RootDefinition, WorkspaceDefinition } from "../productModel";
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia";
export interface LandingStageProps {
root: RootDefinition | null;
backendStatus: BackendStatus;
phase?: RuntimePhase | null;
message?: string | null;
page: EnvironmentPage;
quickActions: readonly WorkspaceDefinition[];
onOpenWorkspace: (workspaceId: string) => void;
}
export function LandingStage({
root,
backendStatus,
phase,
message,
page,
quickActions,
onOpenWorkspace,
}: LandingStageProps) {
const { background } = page;
const hasMedia = background.enabled && background.items.some(
(item) => item.url && item.mediaKind,
);
return (
<section
className="landing-stage"
data-root={root?.id ?? "home"}
data-has-media={hasMedia ? "true" : undefined}
>
<EnvironmentBackgroundMedia background={background} />
<div className="landing-stage__shade" aria-hidden="true" />
<div className="landing-stage__copy">
<span className="section-eyebrow">{page.eyebrow}</span>
<h1>{page.title}</h1>
<p>{page.description}</p>
{quickActions.length ? (
<div className="landing-stage__actions">
{quickActions.map((workspace, index) => (
<Button
key={workspace.id}
variant={index === 0 ? "primary" : "secondary"}
icon={<Icon name={workspace.icon} />}
onClick={() => onOpenWorkspace(workspace.id)}
>
{workspace.label}
</Button>
))}
</div>
) : null}
</div>
<div className="landing-stage__status">
<div>
<span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span>
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
</div>
<div>
<span className="section-eyebrow">ИСТОЧНИК</span>
<StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge>
</div>
<p>{message || "Выберите архитектурный блок в верхней навигации."}</p>
</div>
<footer className="landing-stage__footer">
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
<span>01 / ЛОКАЛЬНЫЙ СТЕНД</span>
</footer>
</section>
);
}
@@ -0,0 +1,730 @@
import { useEffect, useRef, useState } from "react";
import { Icon } from "@nodedc/ui-react";
import type { ObservationSourceDelivery } from "../core/runtime/contracts";
import {
cameraBrowserTransportIdentity,
cameraTransportCallbackIsCurrent,
initialCameraPlaybackRecoveryState,
reduceCameraPlaybackRecovery,
type CameraPlaybackRecoveryEvent,
} from "../core/observation/liveCameraRecovery";
import {
createLiveViewerDiagnosticLifecycle,
createLiveViewerInstanceId,
createLiveViewerLineage,
subscribeToLiveViewerBuildFence,
type LiveViewerDiagnosticLifecycle,
type LiveViewerFailureStage,
} from "../core/observation/liveViewerDiagnostics";
type PlayerStatus = "connecting" | "buffering" | "playing" | "error";
export interface CameraLeaseRetryBudget {
deliveryId: string;
count: number;
}
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const;
export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000;
export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000;
// The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a
// main-thread stall. Keep one bounded append margin above that complete batch;
// crossing either limit replaces the MSE epoch instead of dropping fragments.
export const CAMERA_PENDING_QUEUE_MAX_BYTES = 12 * 1024 * 1024;
export const CAMERA_PENDING_QUEUE_MAX_SEGMENTS = 96;
export type CameraStartupWatchdogStage = "first-media" | "first-playable-frame";
export interface CameraStartupWatchdog {
armFirstMedia: () => void;
markMediaReceived: () => void;
markPlaying: () => void;
clear: () => void;
pendingStage: () => CameraStartupWatchdogStage | null;
}
/**
* Supervise only the disposable browser transport. One fixed first-media
* deadline covers both an MSE that never opens and an open-but-silent socket.
* The first non-empty fragment then starts a separate first-playable-frame
* deadline. Every subsequent fragment extends that deadline: a browser that is
* still receiving the authoritative stream must not destroy its partial MSE
* decode solely because the main thread or decoder needed longer to start.
*/
export function createCameraStartupWatchdog({
schedule,
cancel,
onTimeout,
firstMediaTimeoutMs = CAMERA_FIRST_MEDIA_TIMEOUT_MS,
firstPlayableFrameTimeoutMs = CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS,
}: {
schedule: (callback: () => void, timeoutMs: number) => number;
cancel: (handle: number) => void;
onTimeout: (stage: CameraStartupWatchdogStage) => void;
firstMediaTimeoutMs?: number;
firstPlayableFrameTimeoutMs?: number;
}): CameraStartupWatchdog {
let timer: number | undefined;
let stage: CameraStartupWatchdogStage | null = null;
let mediaReceived = false;
let playing = false;
const clearTimer = () => {
if (timer !== undefined) cancel(timer);
timer = undefined;
stage = null;
};
const arm = (nextStage: CameraStartupWatchdogStage, timeoutMs: number) => {
clearTimer();
stage = nextStage;
timer = schedule(() => {
if (stage !== nextStage || playing) return;
timer = undefined;
stage = null;
onTimeout(nextStage);
}, timeoutMs);
};
return {
armFirstMedia() {
if (mediaReceived || playing || stage !== null) return;
arm("first-media", firstMediaTimeoutMs);
},
markMediaReceived() {
if (playing) return;
mediaReceived = true;
arm("first-playable-frame", firstPlayableFrameTimeoutMs);
},
markPlaying() {
if (playing) return;
playing = true;
clearTimer();
},
clear: clearTimer,
pendingStage: () => stage,
};
}
export function cameraStartupWatchdogRecoveryMessage(
stage: CameraStartupWatchdogStage,
): string {
return stage === "first-media"
? "Камера подключена, но не передаёт медиаданные; восстанавливаем browser-preview."
: "Медиаданные поступают, но первый кадр не воспроизводится; пересоздаём decoder.";
}
export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetryBudget {
return { deliveryId, count: 0 };
}
export function consumeCameraLeaseRetry(
current: CameraLeaseRetryBudget,
deliveryId: string,
): { budget: CameraLeaseRetryBudget; delay: number } {
const count = current.deliveryId === deliveryId ? current.count : 0;
const delayIndex = Math.min(count, CAMERA_LEASE_RETRY_DELAYS.length - 1);
const delay = CAMERA_LEASE_RETRY_DELAYS[delayIndex];
return {
budget: {
deliveryId,
count: Math.min(count + 1, CAMERA_LEASE_RETRY_DELAYS.length),
},
delay,
};
}
export function cameraTransportRecoveryIsCurrent(
activeAuthorityIdentity: string | null,
expectedAuthorityIdentity: string | null,
activeEpoch: number,
expectedEpoch: number,
disposed: boolean,
): boolean {
return Boolean(
expectedAuthorityIdentity
&& activeAuthorityIdentity === expectedAuthorityIdentity
&& cameraTransportCallbackIsCurrent(activeEpoch, expectedEpoch, disposed),
);
}
export function cameraTransportCanOpen(uiBuildStale: boolean): boolean {
return !uiBuildStale;
}
export function cameraPendingQueueCanAccept(
queuedBytes: number,
queuedSegments: number,
incomingBytes: number,
): boolean {
return incomingBytes > 0
&& queuedBytes + incomingBytes <= CAMERA_PENDING_QUEUE_MAX_BYTES
&& queuedSegments + 1 <= CAMERA_PENDING_QUEUE_MAX_SEGMENTS;
}
export function cameraTransportCloseRecoveryMessage(code: number): string {
if (code === 4_008) {
return "Browser-reader отстал от эфира; восстанавливаем текущую камеру.";
}
if (code === 1_008) {
return "Camera adapter освобождает прежний browser-reader; переподключаемся.";
}
if (code === 1_000) {
return "Browser-preview завершился; восстанавливаем текущую камеру.";
}
return "Browser-preview прерван; восстанавливаем текущую камеру.";
}
export function cameraPlaybackRecoveryFailureStage(
event: CameraPlaybackRecoveryEvent,
): LiveViewerFailureStage {
if (event.type === "heartbeat") return "camera-heartbeat-reopen";
if (event.type === "network-online") return "camera-network-online-reopen";
if (event.type === "page-restore") return "camera-page-restore-reopen";
return "camera-visibility-reopen";
}
function websocketUrl(path: string): string {
const url = new URL(path, window.location.href);
if (url.protocol === "http:") url.protocol = "ws:";
if (url.protocol === "https:") url.protocol = "wss:";
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
throw new Error("Адаптер вернул неподдерживаемый адрес видеопотока.");
}
return url.toString();
}
export function MseFmp4WebSocketPlayer({
delivery,
label,
recoveryAuthorityIdentity,
}: {
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" };
label: string;
recoveryAuthorityIdentity: string | null;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const leaseRetryRef = useRef(resetCameraLeaseRetryBudget(delivery.id));
const activeAuthorityRef = useRef(recoveryAuthorityIdentity);
const recoveryPendingAuthorityRef = useRef<string | null>(null);
const transportHealthyRef = useRef(false);
const transportEpochRef = useRef(0);
const transportAuthorityRef = useRef(recoveryAuthorityIdentity);
const activeTransportDisposeRef = useRef<(() => void) | null>(null);
const activeDiagnosticRef = useRef<LiveViewerDiagnosticLifecycle | null>(null);
const diagnosticInstanceIdRef = useRef<string | null>(null);
const diagnosticGenerationRef = useRef(0);
const uiBuildStaleRef = useRef(false);
const [attempt, setAttempt] = useState(0);
const [uiBuildStale, setUiBuildStale] = useState(false);
const [status, setStatus] = useState<PlayerStatus>("connecting");
const [message, setMessage] = useState("Подключение к локальному видеопотоку");
activeAuthorityRef.current = recoveryAuthorityIdentity;
const transportIdentity = cameraBrowserTransportIdentity(
delivery,
recoveryAuthorityIdentity,
);
useEffect(() => subscribeToLiveViewerBuildFence(() => {
// Setting this local fence tears down the effect-owned WebSocket and MSE
// buffer. It intentionally never invokes a plugin or device command.
uiBuildStaleRef.current = true;
activeTransportDisposeRef.current?.();
setUiBuildStale(true);
}), []);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (uiBuildStale || !cameraTransportCanOpen(uiBuildStaleRef.current)) {
video.pause();
video.removeAttribute("src");
video.load();
return;
}
if (transportAuthorityRef.current !== recoveryAuthorityIdentity) {
transportAuthorityRef.current = recoveryAuthorityIdentity;
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
}
let disposed = false;
const transportEpoch = transportEpochRef.current + 1;
transportEpochRef.current = transportEpoch;
diagnosticInstanceIdRef.current ??= createLiveViewerInstanceId();
diagnosticGenerationRef.current += 1;
const diagnosticLifecycle = createLiveViewerDiagnosticLifecycle({
lineage: createLiveViewerLineage(
diagnosticInstanceIdRef.current,
diagnosticGenerationRef.current,
),
});
activeDiagnosticRef.current = diagnosticLifecycle;
diagnosticLifecycle.verifyBuild();
diagnosticLifecycle.post({
eventCode: "live_camera_player_effect_started",
streamId: delivery.id,
transportEpoch,
});
if (!recoveryAuthorityIdentity) {
diagnosticLifecycle.post({
eventCode: "live_camera_playback_authority_missing",
streamId: delivery.id,
transportEpoch,
});
}
const transportIsCurrent = () => cameraTransportCallbackIsCurrent(
transportEpochRef.current,
transportEpoch,
disposed || uiBuildStaleRef.current,
);
let socket: WebSocket | null = null;
let sourceBuffer: SourceBuffer | null = null;
let onBufferUpdateEnd: (() => void) | null = null;
let onBufferError: (() => void) | null = null;
let objectUrl = "";
let retryTimer: number | undefined;
let receivedMedia = false;
let failed = false;
let playingReported = false;
let startupWatchdog: CameraStartupWatchdog | null = null;
transportHealthyRef.current = false;
const recovering = Boolean(
activeAuthorityRef.current
&& recoveryPendingAuthorityRef.current === activeAuthorityRef.current,
);
const queue: ArrayBuffer[] = [];
let queuedBytes = 0;
const fail = (copy: string) => {
if (!transportIsCurrent() || failed) return;
startupWatchdog?.clear();
failed = true;
transportHealthyRef.current = false;
recoveryPendingAuthorityRef.current = null;
setStatus("error");
setMessage(copy);
try {
if (socket && socket.readyState < WebSocket.CLOSING) {
socket.close(4_000, "Live video buffer reset");
}
} catch {
// The manual reconnect button will create a fresh transport and MSE buffer.
}
};
const retryTransport = (
copy: string,
failureStage: LiveViewerFailureStage,
websocketCloseCode?: number,
appendFailure?: {
cameraAppendErrorName: string;
cameraMediaSourceState: "closed" | "open" | "ended";
cameraVideoErrorCode: number | null;
},
) => {
if (!transportIsCurrent() || failed) return;
if (!cameraTransportRecoveryIsCurrent(
activeAuthorityRef.current,
recoveryAuthorityIdentity,
transportEpochRef.current,
transportEpoch,
disposed || uiBuildStaleRef.current,
)) {
fail(copy);
return;
}
startupWatchdog?.clear();
failed = true;
transportHealthyRef.current = false;
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
leaseRetryRef.current = retry.budget;
diagnosticLifecycle.post({
eventCode: "live_camera_transport_restart_requested",
failureStage,
streamId: delivery.id,
cameraQueueBytes: queuedBytes,
cameraQueueSegments: queue.length,
cameraRetryCount: retry.budget.count,
websocketCloseCode,
transportEpoch,
...appendFailure,
});
queue.length = 0;
queuedBytes = 0;
recoveryPendingAuthorityRef.current = recoveryAuthorityIdentity;
setStatus("connecting");
setMessage(copy);
try {
if (socket && socket.readyState < WebSocket.CLOSING) {
socket.close(4_001, "Live video transport recovery");
}
} catch {
// The replacement effect still fences and releases this transport.
}
retryTimer = window.setTimeout(() => {
if (cameraTransportRecoveryIsCurrent(
activeAuthorityRef.current,
recoveryAuthorityIdentity,
transportEpochRef.current,
transportEpoch,
disposed || uiBuildStaleRef.current,
)) {
setAttempt((value) => value + 1);
}
}, retry.delay);
};
if (recoveryAuthorityIdentity) {
startupWatchdog = createCameraStartupWatchdog({
schedule: (callback, timeoutMs) => window.setTimeout(callback, timeoutMs),
cancel: (handle) => window.clearTimeout(handle),
onTimeout: (stage) => {
retryTransport(
cameraStartupWatchdogRecoveryMessage(stage),
stage === "first-media"
? "camera-first-media-timeout"
: "camera-first-playable-timeout",
);
},
});
}
const onPlaying = () => {
if (!transportIsCurrent() || failed) return;
startupWatchdog?.markPlaying();
transportHealthyRef.current = true;
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
recoveryPendingAuthorityRef.current = null;
setStatus("playing");
setMessage("");
if (!playingReported) {
playingReported = true;
diagnosticLifecycle.post({
eventCode: "live_camera_transport_playing",
streamId: delivery.id,
cameraQueueBytes: queuedBytes,
cameraQueueSegments: queue.length,
transportEpoch,
});
}
};
video.addEventListener("playing", onPlaying);
const appendNext = () => {
if (
!transportIsCurrent()
|| failed
|| !sourceBuffer
|| sourceBuffer.updating
|| queue.length === 0
) return;
const chunk = queue.shift();
if (!chunk) return;
queuedBytes -= chunk.byteLength;
try {
sourceBuffer.appendBuffer(chunk);
} catch (error) {
const quotaExceeded = error instanceof DOMException
&& error.name === "QuotaExceededError";
retryTransport(
quotaExceeded
? "Live-буфер переполнен; восстанавливаем канал без накопленной задержки."
: "Не удалось добавить видеосегмент; восстанавливаем browser-preview.",
quotaExceeded ? "camera-append-quota" : "camera-append-error",
undefined,
{
cameraAppendErrorName: error instanceof DOMException
? error.name
: error instanceof Error
? error.name
: "UnknownError",
cameraMediaSourceState: mediaSource.readyState,
cameraVideoErrorCode: video.error?.code ?? null,
},
);
}
};
const enqueue = (chunk: ArrayBuffer) => {
if (!transportIsCurrent() || failed || chunk.byteLength === 0) return;
startupWatchdog?.markMediaReceived();
// Never drop arbitrary fMP4 fragments: the following samples may depend
// on them. A bounded reset is safer and keeps live latency deterministic.
if (!cameraPendingQueueCanAccept(queuedBytes, queue.length, chunk.byteLength)) {
retryTransport(
"Видеодекодер отстал от эфира; восстанавливаем live-буфер.",
"camera-queue-capacity",
);
return;
}
queue.push(chunk);
queuedBytes += chunk.byteLength;
appendNext();
};
const mediaType = delivery.mediaType?.trim();
if (typeof MediaSource === "undefined") {
fail("Этот браузер не поддерживает Media Source Extensions.");
return;
}
if (!mediaType || !MediaSource.isTypeSupported(mediaType)) {
fail(mediaType
? `Браузер не поддерживает ${mediaType}`
: "Адаптер не сообщил MIME/codec fMP4-потока.");
return;
}
setStatus("connecting");
setMessage(recovering
? "Восстановление камеры после разрыва браузерного канала"
: "Подключение к локальному видеопотоку");
const mediaSource = new MediaSource();
objectUrl = URL.createObjectURL(mediaSource);
const onSourceOpen = () => {
if (!transportIsCurrent() || failed) return;
diagnosticLifecycle.post({
eventCode: "live_camera_media_source_open",
streamId: delivery.id,
transportEpoch,
});
try {
sourceBuffer = mediaSource.addSourceBuffer(mediaType);
} catch {
fail("Не удалось создать MSE-буфер для указанного кодека.");
return;
}
onBufferUpdateEnd = () => {
if (!transportIsCurrent() || failed || !sourceBuffer) return;
const buffered = sourceBuffer.buffered;
if (buffered.length > 0) {
const end = buffered.end(buffered.length - 1);
const start = buffered.start(0);
if (end - video.currentTime > 1) video.currentTime = Math.max(0, end - 0.1);
if (!receivedMedia) {
receivedMedia = true;
setStatus("buffering");
setMessage(recovering
? "Запуск первого кадра восстановленной камеры"
: "Запуск первого декодированного кадра");
void video.play().catch(() => undefined);
}
const removeBefore = end - 3;
if (removeBefore > start && !sourceBuffer.updating) {
try {
sourceBuffer.remove(0, removeBefore);
return;
} catch {
// Continue appending; quota handling performs a clean reset.
}
}
}
appendNext();
};
onBufferError = () => {
retryTransport(
"MSE сбросил видеосегмент; восстанавливаем decoder.",
"camera-source-buffer-error",
);
};
sourceBuffer.addEventListener("updateend", onBufferUpdateEnd);
sourceBuffer.addEventListener("error", onBufferError);
try {
socket = new WebSocket(websocketUrl(delivery.url));
} catch (error) {
fail(error instanceof Error ? error.message : "Некорректный адрес видеопотока.");
return;
}
socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => {
if (!transportIsCurrent() || failed) return;
diagnosticLifecycle.post({
eventCode: "live_camera_websocket_open",
streamId: delivery.id,
transportEpoch,
});
startupWatchdog?.armFirstMedia();
setStatus("buffering");
setMessage(recovering
? "Ожидание первого кадра после восстановления"
: "Ожидание первого видеокадра");
});
socket.addEventListener("message", (event) => {
if (!transportIsCurrent() || failed) return;
if (event.data instanceof ArrayBuffer) {
enqueue(event.data);
} else if (event.data instanceof Blob) {
void event.data.arrayBuffer().then(enqueue).catch(() => {
retryTransport(
"Получен повреждённый видеосегмент; восстанавливаем канал.",
"camera-fragment-decode",
);
});
}
});
socket.addEventListener("error", () => {
retryTransport(
"Связь с локальным video adapter потеряна; переподключаемся.",
"camera-websocket-error",
);
});
socket.addEventListener("close", (event) => {
if (!transportIsCurrent() || failed) return;
retryTransport(
cameraTransportCloseRecoveryMessage(event.code),
"camera-websocket-close",
event.code,
);
});
};
// Register before assigning the object URL. `video.src` is the action that
// lets the browser open this MediaSource; a fast sequential MSE lease must
// not fire `sourceopen` in the gap before its only listener exists.
mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
startupWatchdog?.armFirstMedia();
video.src = objectUrl;
const disposeTransport = () => {
if (disposed) return;
disposed = true;
transportHealthyRef.current = false;
startupWatchdog?.clear();
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
queue.length = 0;
socket?.close(1000, "Browser preview transport replaced or hidden");
video.removeEventListener("playing", onPlaying);
mediaSource.removeEventListener("sourceopen", onSourceOpen);
if (sourceBuffer && onBufferUpdateEnd) {
sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd);
}
if (sourceBuffer && onBufferError) {
sourceBuffer.removeEventListener("error", onBufferError);
}
try {
if (sourceBuffer?.updating) sourceBuffer.abort();
} catch {
// The MediaSource can already be closing.
}
try {
if (mediaSource.readyState === "open") mediaSource.endOfStream();
} catch {
// Cleanup must continue even if the browser has already detached MSE.
}
video.pause();
video.removeAttribute("src");
video.load();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
activeTransportDisposeRef.current = disposeTransport;
return () => {
if (activeTransportDisposeRef.current === disposeTransport) {
activeTransportDisposeRef.current = null;
}
if (activeDiagnosticRef.current === diagnosticLifecycle) {
activeDiagnosticRef.current = null;
}
diagnosticLifecycle.dispose();
disposeTransport();
};
}, [attempt, recoveryAuthorityIdentity, transportIdentity, uiBuildStale]);
useEffect(() => {
if (
!recoveryAuthorityIdentity
|| uiBuildStale
|| !cameraTransportCanOpen(uiBuildStaleRef.current)
) return;
let recovery = initialCameraPlaybackRecoveryState(
recoveryAuthorityIdentity,
Date.now(),
);
const dispatchRecovery = (event: CameraPlaybackRecoveryEvent) => {
if (uiBuildStaleRef.current) return;
const decision = reduceCameraPlaybackRecovery(recovery, event, {
activeAuthorityIdentity: activeAuthorityRef.current,
now: Date.now(),
documentVisible: document.visibilityState === "visible",
networkOnline: navigator.onLine !== false,
transportHealthy: transportHealthyRef.current,
});
recovery = decision.state;
if (!decision.reopen) return;
activeDiagnosticRef.current?.post({
eventCode: "live_camera_transport_restart_requested",
failureStage: cameraPlaybackRecoveryFailureStage(event),
streamId: delivery.id,
transportEpoch: transportEpochRef.current,
});
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
recoveryPendingAuthorityRef.current = recoveryAuthorityIdentity;
setStatus("connecting");
setMessage("Восстановление камеры после разрыва браузерного канала");
// The transport effect cleans up the old WebSocket and MSE object before
// opening their replacement. No plugin action or device command occurs.
setAttempt((value) => value + 1);
};
const onVisibilityChange = () => {
dispatchRecovery({
type: document.visibilityState === "hidden"
? "document-hidden"
: "document-visible",
});
};
const onOnline = () => dispatchRecovery({ type: "network-online" });
const onPageShow = (event: PageTransitionEvent) => {
dispatchRecovery({ type: "page-restore", persisted: event.persisted });
};
const heartbeat = window.setInterval(() => {
dispatchRecovery({ type: "heartbeat" });
}, 1_000);
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("online", onOnline);
window.addEventListener("pageshow", onPageShow);
return () => {
window.clearInterval(heartbeat);
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("online", onOnline);
window.removeEventListener("pageshow", onPageShow);
};
}, [delivery.id, recoveryAuthorityIdentity, uiBuildStale]);
return (
<div className="mse-fmp4-player" data-status={status}>
<video
ref={videoRef}
className="observation-media__asset"
aria-label={label}
autoPlay
muted
playsInline
/>
{status !== "playing" ? (
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
<Icon name={status === "error" ? "alert" : "video"} size={20} />
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong>
<span>{message}</span>
{status === "error" ? (
<button
type="button"
onClick={() => {
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
recoveryPendingAuthorityRef.current = null;
setAttempt((value) => value + 1);
}}
>
<Icon name="refresh" size={14} />
Подключиться повторно
</button>
) : null}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,484 @@
import { useState } from "react";
import { ConfirmationModal, Dropdown, Icon } from "@nodedc/ui-react";
import {
type ObservationSessionReplayLaunch,
type ObservationSessionSummary,
} from "../core/observation/sessionArchive";
import { useObservationSessions } from "../core/observation/useObservationSessions";
import type {
ObservationPreparationPhase,
ObservationReplayOutcome,
} from "../core/observation/useObservationSessions";
import "../styles/observation-sessions.css";
type SessionVisualState = "ready" | "processing" | "cold" | "error";
export function observationSessionVisualState(
session: ObservationSessionSummary,
{ pending = false, failed = false }: { pending?: boolean; failed?: boolean } = {},
): SessionVisualState {
if (failed) return "error";
if (pending) return "processing";
if (session.preparation !== null) {
if (["queued", "validating", "exporting", "finalizing"].includes(
session.preparation.state,
)) return "processing";
if (session.preparation.state === "ready" && session.replayable) return "ready";
return "error";
}
if (session.status === "recording") return "processing";
return session.replayable ? "cold" : "error";
}
export function observationSessionVisualLabel(state: SessionVisualState): string {
if (state === "ready") return "Готово";
if (state === "processing") return "Обработка";
if (state === "cold") return "Подготовить";
return "Ошибка";
}
const modalityLabel: Record<string, string> = {
"point-cloud": "облако точек",
pose: "траектория",
trajectory: "траектория",
video: "видео",
image: "изображения",
depth: "глубина",
telemetry: "телеметрия",
};
const preparationLabel: Record<ObservationPreparationPhase, string> = {
requesting: "Запрашиваем подготовку",
queued: "В очереди",
validating: "Проверяем запись",
exporting: "Готовим операторскую сцену",
finalizing: "Завершаем подготовку",
failed: "Подготовка не выполнена",
cancelled: "Подготовка отменена",
};
function progressCopy(phase: ObservationPreparationPhase): string {
// Backend progress values are phase markers and heartbeat revisions, not a
// measured fraction of bytes or frames. Presenting 0.5 as "50%" made a
// healthy long export look stalled, especially after a browser reload.
return preparationLabel[phase];
}
function formatStartedAt(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function formatDuration(seconds: number): string {
const totalSeconds = Math.max(0, Math.round(seconds));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const remainingSeconds = totalSeconds % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
function sessionDescription(session: ObservationSessionSummary): string {
const modalities = session.modalities.length
? session.modalities.map((value) => modalityLabel[value] ?? value).join(" · ")
: "каналы не зафиксированы";
return `${formatStartedAt(session.startedAtUtc)} · ${formatDuration(session.durationSeconds)} · ${modalities}`;
}
export interface ObservationSessionReplayCallbacks {
onReplayBegin?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplayAccepted?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplaySettled?: (
session: ObservationSessionSummary,
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
onDeleteBegin?: (sessionId: string) => void | Promise<void>;
}
export function ObservationSessionSelect({
limit = 100,
disabled = false,
blockedReason = null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
onDeleteBegin,
}: ObservationSessionReplayCallbacks & {
limit?: number;
disabled?: boolean;
blockedReason?: string | null;
}) {
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
limit,
scope: "source",
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
onDeleteBegin,
});
const triggerCopy = sessions.replayProgress
? progressCopy(sessions.replayProgress.phase)
: sessions.state === "loading"
? "Загружаем сессии…"
: "Сохранённые сессии";
const presentedTriggerCopy = blockedReason ?? triggerCopy;
return <>
<Dropdown
className="observation-session-select"
placement="bottom-end"
width={390}
offset={10}
disabled={disabled}
surfaceRole="dialog"
surfaceClassName="observation-session-menu"
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
<button
ref={(node) => {
setAnchorRef(node);
setTriggerRef(node);
}}
type="button"
className="observation-session-select__trigger"
data-active={open ? "true" : undefined}
aria-label="Открыть сохранённые сессии наблюдения"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={surfaceId}
disabled={disabled}
title={blockedReason ?? undefined}
onClick={toggle}
>
<Icon name="database" size={15} />
<span>{presentedTriggerCopy}</span>
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
<Icon name="chevron-down" size={14} />
</button>
)}
>
{({ close }) => (
<div className="observation-session-menu__content">
<header className="observation-session-menu__head">
<div>
<span className="section-eyebrow">ИСТОРИЯ НАБЛЮДЕНИЯ</span>
<strong>Сохранённые сессии</strong>
</div>
<button
type="button"
aria-label="Обновить каталог сессий"
disabled={sessions.state === "loading"}
onClick={() => void sessions.refresh()}
>
<Icon name="refresh" size={14} />
</button>
</header>
{sessions.items.length > 0 ? (
<div className="observation-session-menu__list">
{sessions.items.map((session) => {
const pending = sessions.replayingSessionId === session.id;
const deleting = sessions.deletingSessionId === session.id;
const failed = sessions.failedSessionId === session.id;
const visualState = observationSessionVisualState(session, { pending, failed });
return (
<div
key={session.id}
className="observation-session-option"
data-deleting={deleting ? "true" : undefined}
>
<button
type="button"
className="nodedc-dropdown-option observation-session-option__open"
disabled={pending || deleting || !session.replayable}
onClick={() => {
void sessions.replay(session.id).then((accepted) => {
if (accepted) close();
});
}}
>
<span className="nodedc-dropdown-option__icon">
<i data-session-visual-state={visualState} aria-hidden="true" />
</span>
<span className="nodedc-dropdown-option__body">
<span className="nodedc-dropdown-option__label">
{session.lab &&
!session.label.startsWith(`${session.lab.labId} ·`) ? (
<span className="observation-session-option__lab">
{session.lab.labId}
</span>
) : null}
{session.label}
</span>
<span className="nodedc-dropdown-option__description">
{sessionDescription(session)}
</span>
</span>
<span className="observation-session-option__state">
{deleting ? "Удаление…" : observationSessionVisualLabel(visualState)}
</span>
</button>
<button
type="button"
className="observation-session-option__delete"
aria-label={`Удалить сохранённую сессию ${session.label}`}
disabled={pending || deleting}
onClick={() => setDeleteTarget(session)}
>
<Icon name="trash" size={15} />
</button>
</div>
);
})}
</div>
) : sessions.state === "loading" ? (
<div className="observation-session-menu__empty" role="status">
<span className="busy-indicator" aria-hidden="true" />
<strong>Читаем каталог</strong>
</div>
) : (
<div className="observation-session-menu__empty">
<Icon name={sessions.error ? "alert" : "database"} size={18} />
<strong>{sessions.error ? "Каталог недоступен" : "Сессий пока нет"}</strong>
<span>{sessions.error ?? "Завершённые записи появятся здесь автоматически."}</span>
</div>
)}
{sessions.error && sessions.items.length > 0 ? (
<footer className="observation-session-menu__error" role="alert">
<Icon name="alert" size={13} />
<span>{sessions.error}</span>
{sessions.failedSessionId ? (
<button type="button" onClick={() => void sessions.retry()}>
Повторить
</button>
) : null}
</footer>
) : null}
</div>
)}
</Dropdown>
<ConfirmationModal
open={deleteTarget !== null}
title="Удалить сохранённую сессию?"
description={deleteTarget ? <>
<strong>{deleteTarget.label}</strong>
{deleteTarget.lab ? (
<p>
Будут удалены только LAB-запись из каталога и её подготовленный кэш.
Исходная запись {deleteTarget.lab.sourceSessionId} и зафиксированный
лабораторный результат останутся неизменными.
</p>
) : (
<p>
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
видеоматериалы будут удалены с этого сервера без возможности восстановления.
</p>
)}
{sessions.error && sessions.deletingSessionId === null ? (
<p className="observation-session-delete-error" role="alert">{sessions.error}</p>
) : null}
</> : null}
confirmLabel="Удалить сессию"
pendingLabel="Удаление…"
danger
onClose={() => {
if (sessions.deletingSessionId === null) setDeleteTarget(null);
}}
onConfirm={async () => {
if (!deleteTarget) return;
if (await sessions.remove(deleteTarget.id)) setDeleteTarget(null);
}}
/>
</>;
}
export function ObservationSessionArchive({
limit = 100,
labsOnly = false,
disabled = false,
blockedReason = null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
onDeleteBegin,
}: ObservationSessionReplayCallbacks & {
limit?: number;
labsOnly?: boolean;
disabled?: boolean;
blockedReason?: string | null;
}) {
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
limit,
scope: labsOnly ? "laboratory" : "source",
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
onDeleteBegin,
});
const items = sessions.items;
return <>
<section
className="observation-session-archive"
aria-label={labsOnly ? "Лабораторные записи" : "Сохранённые сессии"}
>
<header className="observation-session-archive__head">
<div>
<span className="section-eyebrow">
{labsOnly ? "ВОСПРОИЗВОДИМЫЕ ЛАБОРАТОРНЫЕ ЗАПИСИ" : "АРХИВ НАБЛЮДЕНИЯ"}
</span>
<h2>{labsOnly ? "Записанные лабораторные работы" : "Сохранённые сессии"}</h2>
<p>
{labsOnly
? "Каждая запись связана с LAB-конфигурацией и открывается как самостоятельный визуальный результат."
: "Выберите запись: сцена и синхронные каналы откроются ниже, не меняя эфир наблюдения."}
</p>
</div>
<button
type="button"
className="observation-session-archive__refresh"
aria-label="Обновить каталог сессий"
disabled={sessions.state === "loading"}
onClick={() => void sessions.refresh()}
>
<Icon name="refresh" size={14} />
<span>Обновить</span>
</button>
</header>
{items.length > 0 ? (
<div className="observation-session-archive__list">
{items.map((session) => {
const pending = sessions.replayingSessionId === session.id;
const deleting = sessions.deletingSessionId === session.id;
const failed = sessions.failedSessionId === session.id;
const visualState = observationSessionVisualState(session, { pending, failed });
return (
<article
key={session.id}
className="observation-session-archive__item"
data-deleting={deleting ? "true" : undefined}
>
<button
type="button"
className="observation-session-archive__open"
disabled={disabled || pending || deleting || !session.replayable}
title={blockedReason ?? undefined}
onClick={() => void sessions.replay(session.id)}
>
<i data-session-visual-state={visualState} aria-hidden="true" />
<span>
<strong>
{session.lab &&
!session.label.startsWith(`${session.lab.labId} ·`) ? (
<small>{session.lab.labId}</small>
) : null}
{session.label}
</strong>
<small>{sessionDescription(session)}</small>
</span>
<em>
{pending && sessions.replayProgress
? progressCopy(sessions.replayProgress.phase)
: observationSessionVisualLabel(visualState)}
</em>
</button>
<button
type="button"
className="observation-session-archive__delete"
aria-label={`Удалить сохранённую сессию ${session.label}`}
disabled={pending || deleting}
onClick={() => setDeleteTarget(session)}
>
<Icon name="trash" size={15} />
</button>
</article>
);
})}
</div>
) : sessions.state === "loading" ? (
<div className="observation-session-archive__empty" role="status">
<span className="busy-indicator" aria-hidden="true" />
<strong>Читаем каталог</strong>
</div>
) : (
<div className="observation-session-archive__empty">
<Icon name={sessions.error ? "alert" : "database"} size={18} />
<strong>
{sessions.error
? "Каталог недоступен"
: labsOnly
? "LAB-записей пока нет"
: "Сессий пока нет"}
</strong>
<span>
{sessions.error
?? (labsOnly
? "Зафиксированные лабораторные записи появятся здесь автоматически."
: "Завершённые записи появятся здесь автоматически.")}
</span>
</div>
)}
{sessions.error && items.length > 0 ? (
<footer className="observation-session-menu__error" role="alert">
<Icon name="alert" size={13} />
<span>{sessions.error}</span>
{sessions.failedSessionId ? (
<button type="button" onClick={() => void sessions.retry()}>
Повторить
</button>
) : null}
</footer>
) : null}
</section>
<ConfirmationModal
open={deleteTarget !== null}
title="Удалить сохранённую сессию?"
description={deleteTarget ? <>
<strong>{deleteTarget.label}</strong>
{deleteTarget.lab ? (
<p>
Будут удалены только LAB-запись из каталога и её подготовленный кэш.
Исходная запись {deleteTarget.lab.sourceSessionId} и зафиксированный
лабораторный результат останутся неизменными.
</p>
) : (
<p>
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
видеоматериалы будут удалены с этого сервера без возможности восстановления.
</p>
)}
</> : null}
confirmLabel="Удалить сессию"
pendingLabel="Удаление…"
danger
onClose={() => {
if (sessions.deletingSessionId === null) setDeleteTarget(null);
}}
onConfirm={async () => {
if (!deleteTarget) return;
if (await sessions.remove(deleteTarget.id)) setDeleteTarget(null);
}}
/>
</>;
}
@@ -0,0 +1,263 @@
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
import type {
ObservationSourceAvailability,
ObservationSourceDescriptor,
ObservationSourceModality,
} from "../core/runtime/contracts";
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
import {
RecordedFmp4Player,
type RecordedObservationPlayback,
} from "./RecordedFmp4Player";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
const sourceIcon: Record<ObservationSourceModality, IconName> = {
"point-cloud": "globe",
video: "video",
image: "image",
depth: "image",
};
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
unverified: "Не подтверждён",
declared: "Канал объявлен",
available: "Доступен",
connecting: "Подключение",
streaming: "Эфир",
degraded: "Нестабильно",
unavailable: "Недоступен",
error: "Ошибка",
};
export function observationSourceStatusLabel(source: ObservationSourceDescriptor): string {
return availabilityCopy[source.availability];
}
export function ObservationMedia({
source,
playback,
prepareRecorded = true,
recordedSessionGate = "ready",
recordedAdmissionKey = null,
onRecordedAdmissionChange,
}: {
source: ObservationSourceDescriptor;
playback?: RecordedObservationPlayback | null;
prepareRecorded?: boolean;
recordedSessionGate?: RecordedAdmissionPhase;
recordedAdmissionKey?: string | null;
onRecordedAdmissionChange?: (
sourceId: string,
state: RecordedCameraAdmissionState,
) => void;
}) {
const sourceSelected = source.activation ? source.activation.selected : true;
const deliveryActive = Boolean(source.delivery && sourceSelected);
if (
deliveryActive &&
source.delivery?.kind === "mse-fmp4-websocket" &&
source.modality === "video"
) {
return (
<MseFmp4WebSocketPlayer
delivery={source.delivery}
label={source.label}
recoveryAuthorityIdentity={liveCameraPlaybackAuthorityIdentity(source)}
/>
);
}
if (
deliveryActive &&
source.delivery?.kind === "recorded-fmp4-manifest" &&
source.modality === "video"
) {
if (!prepareRecorded) {
return (
<div className="observation-media__empty" role="status">
<span className="busy-indicator" aria-hidden="true" />
<strong>Ожидает подготовки</strong>
<span>Канал будет проверен последовательно в рамках атомарной сессии.</span>
</div>
);
}
return (
<RecordedFmp4Player
source={source}
playback={playback}
prepare
sessionGate={recordedSessionGate}
admissionKey={recordedAdmissionKey}
onAdmissionChange={(state) => onRecordedAdmissionChange?.(source.id, state)}
/>
);
}
if (deliveryActive && source.delivery?.kind === "video-url" && source.modality === "video") {
return (
<video
className="observation-media__asset"
src={source.delivery.url}
autoPlay
muted
playsInline
/>
);
}
if (
deliveryActive &&
source.delivery?.kind === "image-url" &&
(source.modality === "image" || source.modality === "depth")
) {
return <img className="observation-media__asset" src={source.delivery.url} alt={source.label} />;
}
if (sourceSelected && source.previewUrl && source.modality === "video") {
return (
<video
className="observation-media__asset"
src={source.previewUrl}
autoPlay
muted
playsInline
/>
);
}
if (
sourceSelected &&
source.previewUrl &&
(source.modality === "image" || source.modality === "depth")
) {
return <img className="observation-media__asset" src={source.previewUrl} alt={source.label} />;
}
return (
<div className="observation-media__empty">
<Icon name={sourceIcon[source.modality]} size={20} />
<strong>
{source.modality === "video" && source.activation && !source.activation.selected
? "Источник отключён"
: observationSourceStatusLabel(source)}
</strong>
<span>
{source.modality === "video"
? source.activation?.controllable
? source.activation.selected
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
: "Канал остановлен оператором; при необходимости его можно открыть снова"
: "Канал известен, browser-preview сейчас недоступен"
: source.description}
</span>
</div>
);
}
export function ObservationSourcePicker({
sources,
visibleSourceIds,
pendingSourceIds,
onToggle,
}: {
sources: readonly ObservationSourceDescriptor[];
visibleSourceIds: ReadonlySet<string>;
pendingSourceIds?: ReadonlySet<string>;
onToggle: (sourceId: string) => void | Promise<boolean>;
}) {
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
return (
<Dropdown
className="scene-source-picker"
placement="bottom-start"
width={340}
offset={10}
surfaceRole="dialog"
surfaceClassName="observation-source-menu"
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
<button
ref={(node) => {
setAnchorRef(node);
setTriggerRef(node);
}}
type="button"
className="scene-source-picker__trigger"
data-active={open || visibleCount > 0 ? "true" : undefined}
aria-label="Источники данных сцены"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={surfaceId}
onClick={toggle}
>
<Icon name="database" size={17} />
{sources.length > 0 ? <span>{visibleCount}</span> : null}
</button>
)}
>
<header className="observation-source-menu__head">
<div>
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
<strong>Источники данных</strong>
</div>
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
</header>
{sources.length ? (
<div className="observation-source-menu__list">
{sources.map((source) => {
const selected = visibleSourceIds.has(source.id);
const pending = pendingSourceIds?.has(source.id) ?? false;
const canOpen = Boolean(
selected ||
source.modality === "point-cloud" ||
source.previewUrl ||
source.delivery ||
source.activation?.controllable,
);
return (
<button
key={source.id}
type="button"
className="nodedc-dropdown-option observation-source-option"
data-selected={selected ? "true" : undefined}
aria-pressed={selected}
disabled={pending || !canOpen}
onClick={() => void onToggle(source.id)}
>
<span className="nodedc-dropdown-option__icon">
<Icon name={sourceIcon[source.modality]} size={16} />
</span>
<span className="nodedc-dropdown-option__body">
<span className="nodedc-dropdown-option__label">{source.label}</span>
<span className="nodedc-dropdown-option__description">
<i data-availability={source.availability} aria-hidden="true" />
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
</span>
</span>
<span className="nodedc-dropdown-option__check">
{selected ? <Icon name="check" size={15} /> : null}
</span>
</button>
);
})}
</div>
) : (
<div className="observation-source-menu__empty">
<Icon name="database" size={18} />
<strong>Каталог пока пуст</strong>
<span>Выберите профиль устройства — его плагин опубликует доступные каналы.</span>
</div>
)}
<footer className="observation-source-menu__foot">
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
модель оборудования.
</footer>
</Dropdown>
);
}
@@ -0,0 +1,197 @@
import { Button, Icon, Select } from "@nodedc/ui-react";
import type { ObservationTimelineMode } from "../core/runtime/contracts";
export function ObservationTimeline({
active,
sourceCount,
mode = "live-only",
seekable = false,
synchronization = "host-arrival-best-effort",
rangeNs = null,
currentNs = null,
playing = false,
onSeek,
onPlayingChange,
onJumpToEnd,
showJumpToEnd = true,
playbackRate,
onPlaybackRateChange,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
className = "",
}: {
active: boolean;
sourceCount: number;
mode?: ObservationTimelineMode;
seekable?: boolean;
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
rangeNs?: { min: number; max: number } | null;
currentNs?: number | null;
playing?: boolean;
onSeek?: (timeNs: number) => void;
onPlayingChange?: (playing: boolean) => void;
onJumpToEnd?: () => void;
showJumpToEnd?: boolean;
playbackRate?: number;
onPlaybackRateChange?: (rate: number) => void;
accumulationSeconds?: number;
onAccumulationChange?: (value: number) => void;
onAccumulationCommit?: () => void;
className?: string;
}) {
const playbackRange = normalizeTimelineRange(rangeNs);
const buffered = Boolean(
seekable &&
(mode === "buffered" || mode === "recorded") &&
playbackRange,
);
const elapsedSeconds = playbackRange
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
: 0;
const durationSeconds = playbackRange
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
: 0;
const synchronizationLabel = {
"host-arrival-best-effort": "Синхронизация по приходу",
"shared-clock": "Общие часы",
"frame-accurate": "Покадровая синхронизация",
}[synchronization];
const accumulationValue = accumulationSeconds === undefined
? null
: normalizeAccumulationSeconds(accumulationSeconds);
return (
<div
className={`observation-timeline ${className}`.trim()}
data-active={active ? "true" : undefined}
data-mode={mode}
data-accumulation={accumulationValue !== null ? "true" : undefined}
>
{accumulationValue !== null ? (
<div className="observation-timeline__accumulation">
<span>Накопление</span>
<input
className="observation-timeline__track"
type="range"
min={0}
max={120}
step={1}
value={accumulationValue}
aria-label="Окно накопления облака точек"
aria-valuetext={formatAccumulationDuration(accumulationValue)}
onChange={(event) =>
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
onPointerUp={onAccumulationCommit}
onTouchEnd={onAccumulationCommit}
onKeyUp={onAccumulationCommit}
onBlur={onAccumulationCommit}
/>
<code>{formatAccumulationDuration(accumulationValue)}</code>
</div>
) : null}
<div className="observation-timeline__playback">
<Button
size="compact"
variant="ghost"
disabled={!buffered || !onSeek}
aria-label="Перейти к началу"
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
>
<Icon name="chevron-left" />
</Button>
<Button
className="observation-timeline__transport"
size="compact"
variant="ghost"
disabled={!buffered || !onPlayingChange}
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
icon={<Icon name={playing ? "stop" : "play"} />}
onClick={() => onPlayingChange?.(!playing)}
/>
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
<Select
label="Скорость воспроизведения"
value={String(playbackRate)}
options={[
{ value: "0.5", label: "0,5×" },
{ value: "1", label: "1×" },
{ value: "2", label: "2×" },
]}
variant="inline"
menuWidth="anchor"
onChange={(value) => onPlaybackRateChange(Number(value))}
/>
) : null}
<input
className="observation-timeline__track"
type="range"
min={0}
max={Math.max(durationSeconds, 0.001)}
step={0.01}
value={Math.min(elapsedSeconds, durationSeconds)}
disabled={!buffered || !onSeek}
aria-label="Позиция воспроизведения"
onChange={(event) => {
if (!playbackRange) return;
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
}}
/>
<div className="observation-timeline__meta">
<code>
{buffered
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
: active ? "LIVE" : "—:—:—.———"}
</code>
<small>
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
</small>
</div>
{showJumpToEnd ? (
<button
type="button"
className="observation-timeline__follow"
data-active={!buffered && active ? "true" : undefined}
disabled={buffered ? !onJumpToEnd : true}
onClick={onJumpToEnd}
>
{buffered ? "К КОНЦУ" : "ЭФИР"}
</button>
) : null}
</div>
</div>
);
}
export function normalizeAccumulationSeconds(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(120, Math.max(0, Math.round(value)));
}
export function formatAccumulationDuration(value: number): string {
const seconds = normalizeAccumulationSeconds(value);
return seconds === 0 ? "Кадр" : `${seconds} с`;
}
export function normalizeTimelineRange(
range: { min: number; max: number } | null | undefined,
): { min: number; max: number } | null {
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
if (range.max <= range.min) return null;
return range;
}
export function timelineOffsetSeconds(
range: { min: number; max: number },
currentNs: number,
): number {
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
return Math.max(0, (clamped - range.min) / 1_000_000_000);
}
export function formatTimelineDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
const wholeMinutes = Math.floor(seconds / 60);
const remaining = seconds - wholeMinutes * 60;
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
import { StatusBadge } from "@nodedc/ui-react";
import type { E30EngineeringGeneration } from "../../core/laboratory/e30Engineering";
export function E30EngineeringGenerationSummary({
generation,
}: {
generation: E30EngineeringGeneration;
}) {
const summary = generation.summary;
const confirmed = summary.verdictDistribution.confirmed ?? 0;
const corrected = summary.verdictDistribution.corrected ?? 0;
return (
<section
className="e30-engineering-generation"
aria-label="Результат A3 engineering generation"
>
<header>
<div>
<span className="section-eyebrow">A3 · IMMUTABLE ENGINEERING GENERATION</span>
</div>
<StatusBadge
tone={summary.humanExceptionCount ? "warning" : "accent"}
>
{summary.reviewedItemCount} / {summary.itemCount}
</StatusBadge>
</header>
<dl>
<div>
<dt>Подтверждено</dt>
<dd>{confirmed.toLocaleString("ru-RU")}</dd>
</div>
<div>
<dt>Исправлено</dt>
<dd>{corrected.toLocaleString("ru-RU")}</dd>
</div>
<div>
<dt>Исключения</dt>
<dd>{summary.humanExceptionCount.toLocaleString("ru-RU")}</dd>
</div>
<div>
<dt>Средняя уверенность</dt>
<dd>
{(summary.meanConfidence * 100).toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})}
%
</dd>
</div>
</dl>
</section>
);
}
@@ -0,0 +1,74 @@
import type { E30ReviewItemDetail } from "../../core/laboratory/e30Review";
function formatNumber(value: number): string {
return value.toLocaleString("ru-RU", { maximumFractionDigits: 3 });
}
function evidenceRange(item: E30ReviewItemDetail): string {
const value = item.snapshot.rangeM ?? item.snapshot.nearestRangeM;
return value === null ? "недоступна" : `${formatNumber(value)} м`;
}
export function E30EvidenceTelemetry({
detail,
mode,
}: {
detail: E30ReviewItemDetail;
mode: "camera" | "3d";
}) {
return (
<aside
className="e30-evidence-telemetry"
aria-label="Параметры доказательства"
>
<span>
{mode === "camera"
? "Точный camera frame · LiDAR projection"
: "Map frame · Z вверх"}
</span>
<dl>
<div>
<dt>Кадр / время</dt>
<dd>
{formatNumber(detail.sourceFrameIndex)}
{" · "}
{formatNumber(detail.sessionSeconds)} с
</dd>
</div>
<div>
<dt>LiDAR</dt>
<dd>{formatNumber(detail.materialization.projectedPointCount)}</dd>
</div>
<div>
<dt>Кандидаты</dt>
<dd>{formatNumber(detail.materialization.candidatePointCount)}</dd>
</div>
<div>
<dt>Выбрано / отклонено</dt>
<dd>
{formatNumber(detail.materialization.selectedPointCount)}
{" / "}
{formatNumber(detail.materialization.rejectedCandidatePointCount)}
</dd>
</div>
<div>
<dt>Класс E29</dt>
<dd>{detail.snapshot.geometryStatus}</dd>
</div>
<div>
<dt>Дальность</dt>
<dd>{evidenceRange(detail)}</dd>
</div>
</dl>
<div>
<span>Точные индексы LiDAR</span>
<code>
{detail.selected.sourceIndices.length
? detail.selected.sourceIndices.slice(0, 18).join(", ")
: "нет выбранных точек"}
{detail.selected.sourceIndices.length > 18 ? " …" : ""}
</code>
</div>
</aside>
);
}
@@ -0,0 +1,163 @@
import {
useEffect,
useLayoutEffect,
useRef,
type ReactNode,
} from "react";
import {
Icon,
IconButton,
SegmentedControl,
} from "@nodedc/ui-react";
export interface LaboratoryEvidenceViewerMode<T extends string> {
value: T;
label: string;
}
export function LaboratoryEvidenceViewer<
T extends string,
U extends string = string,
>({
label,
className,
mode,
modes,
expanded,
onModeChange,
onExpandedChange,
actions,
secondaryMode,
overlay,
transport,
trailingActions,
modeControlsVisible = true,
chromeLayout = "overlay",
children,
}: {
label: string;
className?: string;
mode: T;
modes: readonly LaboratoryEvidenceViewerMode<T>[];
expanded: boolean;
onModeChange: (mode: T) => void;
onExpandedChange: (expanded: boolean) => void;
actions?: ReactNode;
secondaryMode?: {
value: U;
modes: readonly LaboratoryEvidenceViewerMode<U>[];
label: string;
onChange: (mode: U) => void;
};
overlay?: ReactNode;
transport?: ReactNode;
trailingActions?: ReactNode;
modeControlsVisible?: boolean;
chromeLayout?: "overlay" | "stacked";
children: ReactNode;
}) {
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
const viewerRef = useRef<HTMLDialogElement | null>(null);
useLayoutEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
if (viewer.open) viewer.close();
if (expanded) {
viewer.showModal();
} else {
viewer.show();
}
}, [expanded]);
useEffect(() => {
if (!expanded) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
onExpandedChange(false);
window.requestAnimationFrame(() => expandButtonRef.current?.focus());
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [expanded, onExpandedChange]);
const controls = (
<div className="laboratory-evidence-viewer__controls">
{actions}
{modeControlsVisible && secondaryMode ? (
<SegmentedControl
value={secondaryMode.value}
items={[...secondaryMode.modes]}
label={secondaryMode.label}
onChange={secondaryMode.onChange}
/>
) : null}
{modeControlsVisible ? (
<SegmentedControl
value={mode}
items={[...modes]}
label={`${label}: режим представления`}
onChange={onModeChange}
/>
) : null}
{trailingActions}
<IconButton
ref={expandButtonRef}
label={expanded ? `Свернуть ${label}` : `Развернуть ${label}`}
onClick={() => onExpandedChange(!expanded)}
>
<Icon name={expanded ? "minimize" : "expand"} size={16} />
</IconButton>
</div>
);
const viewer = (
<dialog
ref={viewerRef}
role="region"
className={[
"laboratory-evidence-viewer",
className,
].filter(Boolean).join(" ")}
data-expanded={expanded ? "true" : undefined}
data-mode-controls={modeControlsVisible ? undefined : "content"}
data-chrome-layout={chromeLayout}
aria-label={label}
>
{chromeLayout === "stacked" ? (
<>
<div className="laboratory-evidence-viewer__header">
<div className="laboratory-evidence-viewer__header-context">
{overlay}
</div>
{controls}
</div>
<div className="laboratory-evidence-viewer__stage">
{children}
</div>
{transport ? (
<div className="laboratory-evidence-viewer__transport">
{transport}
</div>
) : null}
</>
) : (
<>
<div className="laboratory-evidence-viewer__stage">
{children}
</div>
{overlay}
{transport ? (
<div className="laboratory-evidence-viewer__transport">
{transport}
</div>
) : null}
{controls}
</>
)}
</dialog>
);
return viewer;
}
@@ -0,0 +1,804 @@
import {
forwardRef,
type CSSProperties,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import {
recordedEvidenceSemanticCssColor,
resolveRecordedEvidenceSemanticRgb,
type RecordedEvidenceSemanticClass,
type RecordedEvidenceSemanticPaletteEntry,
} from "./RecordedEvidenceSemanticMaskOverlay";
export type LaboratoryMetricPoint3 = readonly [number, number, number];
export type LaboratoryMetricDecision = "threat" | "not-threat" | "unknown";
export type LaboratoryMetricSceneMode = "3d" | "plan";
export interface LaboratoryMetricObstacleVisual {
id: string;
decision: LaboratoryMetricDecision;
state: "current" | "retained" | "held" | "expired";
centroidBodyXyzM: LaboratoryMetricPoint3;
cellCentersBodyXyzM: readonly LaboratoryMetricPoint3[];
occupancySource?: "baseline" | "mixed" | "additive-low-step";
}
export interface LaboratoryMetricRigVisual {
lengthM: number;
widthM: number;
nominalSensorHeightM: number;
}
export interface LaboratoryMetricCorridorVisual {
forwardLengthM: number;
rearMarginM: number;
halfWidthM: number;
}
export type LaboratoryMetricCellState =
| "unobserved"
| "ground-support"
| "nonground-occupied"
| "unknown-rejected";
export interface LaboratoryMetricCellEvidence {
centerBodyXyM: readonly [number, number];
zBoundsM: readonly [number | null, number | null];
state: LaboratoryMetricCellState;
}
export interface LaboratoryMetricLegendEntry {
id: LaboratoryMetricDecision
| LaboratoryMetricCellState
| "context"
| "local-surface"
| "rolling"
| "low-step";
label: string;
}
export function laboratoryMetricLegendEntries({
pointCloudCount,
localSurfaceCount,
obstacles,
showCurrentIncrement,
showLocalSurface,
showRollingMap,
showLowStep = true,
}: {
pointCloudCount: number;
localSurfaceCount: number;
obstacles: readonly LaboratoryMetricObstacleVisual[];
showCurrentIncrement: boolean;
showLocalSurface: boolean;
showRollingMap: boolean;
showLowStep?: boolean;
}): readonly LaboratoryMetricLegendEntry[] {
const visibleObstacles = obstacles.filter((obstacle) => (
(showLowStep || obstacle.occupancySource === undefined || obstacle.occupancySource === "baseline")
&& (obstacle.state === "current"
? showCurrentIncrement
: obstacle.state === "retained"
? showRollingMap
: false)
));
const decisions = new Set(visibleObstacles.map(({ decision }) => decision));
const entries: LaboratoryMetricLegendEntry[] = [];
if (decisions.has("threat")) entries.push({ id: "threat", label: "Угроза" });
if (decisions.has("not-threat")) entries.push({ id: "not-threat", label: "Вне коридора" });
if (decisions.has("unknown")) entries.push({ id: "unknown", label: "Неизвестно" });
if (
showLowStep
&& visibleObstacles.some((obstacle) => (
obstacle.occupancySource !== undefined && obstacle.occupancySource !== "baseline"
))
) {
entries.push({ id: "low-step", label: "LOW-STEP · добавлено системой" });
}
if (showCurrentIncrement && pointCloudCount > 0) {
entries.push({ id: "context", label: "Текущий кадр" });
}
if (showLocalSurface && localSurfaceCount > 0) {
entries.push({ id: "local-surface", label: "Локальная SLAM-поверхность" });
}
if (
showRollingMap
&& visibleObstacles.some((obstacle) => (
obstacle.state === "retained" && obstacle.cellCentersBodyXyzM.length > 0
))
) {
entries.push({ id: "rolling", label: "Занято на накопленной карте" });
}
return entries;
}
function tokenColor(
host: HTMLElement,
token: string,
fallback: readonly [number, number, number],
): THREE.Color {
const value = getComputedStyle(host).getPropertyValue(token).trim();
if (value.startsWith("#")) return new THREE.Color(value);
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
return new THREE.Color(red / 255, green / 255, blue / 255);
}
function disposeRenderable(object: THREE.Object3D): void {
const renderable = object as THREE.Object3D & {
geometry?: THREE.BufferGeometry;
material?: THREE.Material | THREE.Material[];
};
renderable.geometry?.dispose();
const materials = Array.isArray(renderable.material)
? renderable.material
: renderable.material
? [renderable.material]
: [];
materials.forEach((material) => material.dispose());
}
function clearGroup(group: THREE.Group): void {
while (group.children.length) {
const child = group.children[0];
if (!child) break;
group.remove(child);
child.traverse(disposeRenderable);
}
}
function scenePoint(point: LaboratoryMetricPoint3): LaboratoryMetricPoint3 {
return [point[0], point[2], -point[1]];
}
function positions(points: readonly LaboratoryMetricPoint3[]): Float32Array {
const result = new Float32Array(points.length * 3);
points.forEach((point, index) => {
const [x, y, z] = scenePoint(point);
const offset = index * 3;
result[offset] = x;
result[offset + 1] = y;
result[offset + 2] = z;
});
return result;
}
function updatePointPositions(
geometry: THREE.BufferGeometry,
points: readonly LaboratoryMetricPoint3[],
): void {
const requiredValues = points.length * 3;
let attribute = geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
if (!attribute || attribute.array.length < requiredValues) {
let capacity = 3;
while (capacity < requiredValues) capacity *= 2;
attribute = new THREE.BufferAttribute(new Float32Array(capacity), 3);
attribute.setUsage(THREE.DynamicDrawUsage);
geometry.setAttribute("position", attribute);
}
const values = attribute.array as Float32Array;
points.forEach((point, index) => {
const [x, y, z] = scenePoint(point);
const offset = index * 3;
values[offset] = x;
values[offset + 1] = y;
values[offset + 2] = z;
});
attribute.needsUpdate = true;
geometry.setDrawRange(0, points.length);
}
function ensurePointColors(
geometry: THREE.BufferGeometry,
pointCount: number,
): THREE.BufferAttribute {
const requiredValues = pointCount * 3;
let attribute = geometry.getAttribute("color") as THREE.BufferAttribute | undefined;
if (!attribute || attribute.array.length < requiredValues) {
let capacity = 3;
while (capacity < requiredValues) capacity *= 2;
attribute = new THREE.BufferAttribute(new Float32Array(capacity), 3);
attribute.setUsage(THREE.DynamicDrawUsage);
geometry.setAttribute("color", attribute);
}
return attribute;
}
function decisionColor(
host: HTMLElement,
decision: LaboratoryMetricDecision,
): THREE.Color {
if (decision === "threat") {
return tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112]);
}
if (decision === "not-threat") {
return tokenColor(host, "--nodedc-success-rgb", [181, 255, 90]);
}
return tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92]);
}
function obstacleColor(
host: HTMLElement,
obstacle: LaboratoryMetricObstacleVisual,
): THREE.Color {
if (obstacle.occupancySource !== "baseline") {
return tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]);
}
return decisionColor(host, obstacle.decision);
}
export interface LaboratoryMetricEvidenceSceneHandle {
resetView: () => void;
}
export const LaboratoryMetricEvidenceScene = forwardRef<
LaboratoryMetricEvidenceSceneHandle,
{
pointCloudBodyXyzM: readonly LaboratoryMetricPoint3[];
localSurfaceBodyXyzM: readonly LaboratoryMetricPoint3[];
obstacles: readonly LaboratoryMetricObstacleVisual[];
rig: LaboratoryMetricRigVisual;
corridor: LaboratoryMetricCorridorVisual;
occupiedVoxelSizeM: number;
mode: LaboratoryMetricSceneMode;
label: string;
showCurrentIncrement: boolean;
showLocalSurface: boolean;
showRollingMap: boolean;
showLowStep?: boolean;
pointSemanticClassIds?: readonly (number | null)[];
semanticClasses?: readonly RecordedEvidenceSemanticClass[];
semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[];
classifiedCells?: readonly LaboratoryMetricCellEvidence[];
classifiedCellSizeM?: number;
showClassifiedCells?: boolean;
}
>(function LaboratoryMetricEvidenceScene({
pointCloudBodyXyzM,
localSurfaceBodyXyzM,
obstacles,
rig,
corridor,
occupiedVoxelSizeM,
mode,
label,
showCurrentIncrement,
showLocalSurface,
showRollingMap,
showLowStep = true,
pointSemanticClassIds,
semanticClasses,
semanticPalette,
classifiedCells = [],
classifiedCellSizeM = 0.45,
showClassifiedCells = true,
}, ref) {
const hostRef = useRef<HTMLDivElement | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const staticContentRef = useRef<THREE.Group | null>(null);
const dynamicContentRef = useRef<THREE.Group | null>(null);
const classifiedContentRef = useRef<THREE.Group | null>(null);
const localSurfacePointsRef = useRef<THREE.Points | null>(null);
const currentIncrementPointsRef = useRef<THREE.Points | null>(null);
const classifiedMeshesRef = useRef<ReadonlyMap<LaboratoryMetricCellState, THREE.InstancedMesh>>(
new Map(),
);
const [renderError, setRenderError] = useState<string | null>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: "high-performance",
});
} catch {
setRenderError("Браузер не смог создать метрическую 3D-сцену.");
return;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(tokenColor(host, "--nodedc-canvas", [5, 5, 6]), 1);
renderer.domElement.setAttribute("role", "img");
host.prepend(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 300);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = true;
controls.enableZoom = true;
controls.screenSpacePanning = true;
controls.minDistance = 0.4;
controls.maxDistance = 80;
const staticContent = new THREE.Group();
const dynamicContent = new THREE.Group();
const classifiedContent = new THREE.Group();
const localSurfacePoints = new THREE.Points(
new THREE.BufferGeometry(),
new THREE.PointsMaterial({
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
size: 1.3,
sizeAttenuation: false,
transparent: true,
opacity: 0.42,
depthWrite: false,
}),
);
const currentIncrementPoints = new THREE.Points(
new THREE.BufferGeometry(),
new THREE.PointsMaterial({
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
size: 1.55,
sizeAttenuation: false,
transparent: true,
opacity: 0.58,
depthWrite: false,
}),
);
localSurfacePoints.visible = false;
localSurfacePoints.frustumCulled = false;
currentIncrementPoints.visible = false;
currentIncrementPoints.frustumCulled = false;
scene.add(
staticContent,
localSurfacePoints,
currentIncrementPoints,
dynamicContent,
classifiedContent,
);
sceneRef.current = scene;
cameraRef.current = camera;
controlsRef.current = controls;
staticContentRef.current = staticContent;
dynamicContentRef.current = dynamicContent;
classifiedContentRef.current = classifiedContent;
localSurfacePointsRef.current = localSurfacePoints;
currentIncrementPointsRef.current = currentIncrementPoints;
const resize = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
};
const observer = new ResizeObserver(resize);
observer.observe(host);
resize();
let animationFrame = 0;
const render = () => {
animationFrame = window.requestAnimationFrame(render);
controls.update();
renderer.render(scene, camera);
};
render();
return () => {
window.cancelAnimationFrame(animationFrame);
observer.disconnect();
controls.dispose();
scene.traverse(disposeRenderable);
renderer.dispose();
renderer.domElement.remove();
sceneRef.current = null;
cameraRef.current = null;
controlsRef.current = null;
staticContentRef.current = null;
dynamicContentRef.current = null;
classifiedContentRef.current = null;
localSurfacePointsRef.current = null;
currentIncrementPointsRef.current = null;
classifiedMeshesRef.current = new Map();
};
}, []);
useEffect(() => {
const canvas = hostRef.current?.querySelector("canvas");
if (canvas) canvas.setAttribute("aria-label", label);
}, [label]);
useEffect(() => {
const points = localSurfacePointsRef.current;
if (!points) return;
points.visible = showLocalSurface && localSurfaceBodyXyzM.length > 0;
if (points.visible) updatePointPositions(points.geometry, localSurfaceBodyXyzM);
}, [localSurfaceBodyXyzM, showLocalSurface]);
useEffect(() => {
const host = hostRef.current;
const points = currentIncrementPointsRef.current;
if (!host || !points) return;
points.visible = showCurrentIncrement && pointCloudBodyXyzM.length > 0;
if (!points.visible) return;
updatePointPositions(points.geometry, pointCloudBodyXyzM);
const material = points.material as THREE.PointsMaterial;
const hasAlignedSemanticClasses =
pointSemanticClassIds !== undefined
&& pointSemanticClassIds.length === pointCloudBodyXyzM.length
&& semanticClasses !== undefined
&& semanticPalette !== undefined;
if (!hasAlignedSemanticClasses) {
if (material.vertexColors) {
material.vertexColors = false;
material.needsUpdate = true;
}
material.color.copy(tokenColor(host, "--nodedc-text-muted", [147, 151, 159]));
return;
}
const declaredIds = new Set(semanticClasses.map((item) => item.id));
const colorsByClassId = new Map<number, readonly [number, number, number]>();
for (const entry of semanticPalette) {
if (!declaredIds.has(entry.classId)) continue;
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
if (rgb) colorsByClassId.set(entry.classId, rgb);
}
const context = tokenColor(host, "--nodedc-text-muted", [147, 151, 159]);
const colorAttribute = ensurePointColors(points.geometry, pointCloudBodyXyzM.length);
const pointColors = colorAttribute.array as Float32Array;
pointSemanticClassIds.forEach((classId, index) => {
const rgb = classId === null ? undefined : colorsByClassId.get(classId);
const offset = index * 3;
pointColors[offset] = rgb ? rgb[0] / 255 : context.r;
pointColors[offset + 1] = rgb ? rgb[1] / 255 : context.g;
pointColors[offset + 2] = rgb ? rgb[2] / 255 : context.b;
});
colorAttribute.needsUpdate = true;
if (!material.vertexColors) {
material.vertexColors = true;
material.needsUpdate = true;
}
material.color.setRGB(1, 1, 1);
}, [
pointCloudBodyXyzM,
pointSemanticClassIds,
semanticClasses,
semanticPalette,
showCurrentIncrement,
]);
useEffect(() => {
const host = hostRef.current;
const content = dynamicContentRef.current;
if (!host || !content) return;
clearGroup(content);
for (const obstacle of obstacles) {
if (
(
!showLowStep
&& obstacle.occupancySource !== undefined
&& obstacle.occupancySource !== "baseline"
)
||
(obstacle.state === "current" && !showCurrentIncrement)
|| (obstacle.state === "retained" && !showRollingMap)
|| obstacle.state === "held"
|| obstacle.state === "expired"
) {
continue;
}
const color = obstacleColor(host, obstacle);
if (obstacle.state === "retained") {
const geometry = new THREE.BoxGeometry(
occupiedVoxelSizeM * 0.82,
occupiedVoxelSizeM * 0.82,
occupiedVoxelSizeM * 0.82,
);
const material = new THREE.MeshBasicMaterial({
color,
wireframe: true,
transparent: true,
opacity: 0.34,
depthWrite: false,
});
const voxels = new THREE.InstancedMesh(
geometry,
material,
obstacle.cellCentersBodyXyzM.length,
);
const matrix = new THREE.Matrix4();
obstacle.cellCentersBodyXyzM.forEach((point, index) => {
matrix.makeTranslation(...scenePoint(point));
voxels.setMatrixAt(index, matrix);
});
voxels.instanceMatrix.needsUpdate = true;
voxels.userData.evidenceId = obstacle.id;
content.add(voxels);
} else {
const cellsGeometry = new THREE.BufferGeometry();
cellsGeometry.setAttribute(
"position",
new THREE.BufferAttribute(positions(obstacle.cellCentersBodyXyzM), 3),
);
content.add(new THREE.Points(
cellsGeometry,
new THREE.PointsMaterial({
color,
size: 4.4,
sizeAttenuation: false,
transparent: true,
opacity: 0.96,
depthWrite: false,
}),
));
const centroid = new THREE.Mesh(
new THREE.SphereGeometry(0.065, 12, 8),
new THREE.MeshBasicMaterial({ color }),
);
centroid.position.fromArray(scenePoint(obstacle.centroidBodyXyzM));
centroid.userData.evidenceId = obstacle.id;
content.add(centroid);
}
}
}, [
obstacles,
occupiedVoxelSizeM,
showCurrentIncrement,
showRollingMap,
showLowStep,
]);
useEffect(() => {
const host = hostRef.current;
const content = classifiedContentRef.current;
if (!host || !content) return;
content.visible = showClassifiedCells && classifiedCells.length > 0;
if (!content.visible) return;
const requiredCapacity = classifiedCells.length;
const currentMeshes = classifiedMeshesRef.current;
const firstMesh = currentMeshes.values().next().value as THREE.InstancedMesh | undefined;
const needsAllocation = !firstMesh
|| Number(firstMesh.userData.capacity ?? 0) < requiredCapacity
|| Number(firstMesh.userData.cellSizeM ?? 0) !== classifiedCellSizeM;
if (needsAllocation) {
clearGroup(content);
const meshes = new Map<LaboratoryMetricCellState, THREE.InstancedMesh>();
const states: readonly LaboratoryMetricCellState[] = [
"unobserved",
"ground-support",
"nonground-occupied",
"unknown-rejected",
];
for (const state of states) {
const color = state === "ground-support"
? tokenColor(host, "--nodedc-success-rgb", [181, 255, 90])
: state === "nonground-occupied"
? tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112])
: state === "unknown-rejected"
? tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92])
: tokenColor(host, "--nodedc-text-muted", [96, 99, 106]);
const mesh = new THREE.InstancedMesh(
new THREE.BoxGeometry(classifiedCellSizeM * 0.92, 1, classifiedCellSizeM * 0.92),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: state === "unobserved" ? 0.035 : state === "ground-support" ? 0.12 : 0.24,
depthWrite: false,
}),
requiredCapacity,
);
mesh.count = 0;
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.frustumCulled = false;
mesh.userData.capacity = requiredCapacity;
mesh.userData.cellSizeM = classifiedCellSizeM;
meshes.set(state, mesh);
content.add(mesh);
}
classifiedMeshesRef.current = meshes;
}
const meshes = classifiedMeshesRef.current;
const counts = new Map<LaboratoryMetricCellState, number>();
const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
const scale = new THREE.Vector3(1, 1, 1);
const rotation = new THREE.Quaternion();
for (const cell of classifiedCells) {
const mesh = meshes.get(cell.state);
if (!mesh) continue;
const index = counts.get(cell.state) ?? 0;
const minimum = cell.zBoundsM[0];
const maximum = cell.zBoundsM[1];
const height = minimum === null || maximum === null
? 0.018
: Math.max(0.018, maximum - minimum);
const centerZ = minimum === null || maximum === null
? -0.012
: (minimum + maximum) / 2;
const [sceneX, sceneY, sceneZ] = scenePoint([
cell.centerBodyXyM[0],
cell.centerBodyXyM[1],
centerZ,
]);
position.set(sceneX, sceneY, sceneZ);
scale.set(1, height, 1);
matrix.compose(position, rotation, scale);
mesh.setMatrixAt(index, matrix);
counts.set(cell.state, index + 1);
}
for (const [state, mesh] of meshes) {
mesh.count = counts.get(state) ?? 0;
mesh.instanceMatrix.needsUpdate = true;
}
}, [classifiedCellSizeM, classifiedCells, showClassifiedCells]);
useEffect(() => {
const host = hostRef.current;
const content = staticContentRef.current;
if (!host || !content) return;
clearGroup(content);
const corridorLength = rig.lengthM / 2 + corridor.forwardLengthM + corridor.rearMarginM;
const corridorCenterX = (rig.lengthM / 2 + corridor.forwardLengthM - corridor.rearMarginM) / 2;
const corridorMesh = new THREE.Mesh(
new THREE.PlaneGeometry(corridorLength, corridor.halfWidthM * 2),
new THREE.MeshBasicMaterial({
color: tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]),
transparent: true,
opacity: 0.11,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
corridorMesh.rotation.x = -Math.PI / 2;
corridorMesh.position.set(corridorCenterX, 0.01, 0);
content.add(corridorMesh);
const corridorOutline = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(corridorLength, 0.01, corridor.halfWidthM * 2)),
new THREE.LineBasicMaterial({
color: tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]),
transparent: true,
opacity: 0.7,
}),
);
corridorOutline.position.set(corridorCenterX, 0.015, 0);
content.add(corridorOutline);
const body = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(rig.lengthM, 0.34, rig.widthM)),
new THREE.LineBasicMaterial({
color: tokenColor(host, "--nodedc-foreground-rgb", [245, 245, 245]),
transparent: true,
opacity: 0.86,
}),
);
body.position.y = 0.17;
content.add(body);
const lidar = new THREE.Mesh(
new THREE.CylinderGeometry(0.08, 0.08, 0.08, 20),
new THREE.MeshBasicMaterial({
color: tokenColor(host, "--nodedc-foreground-rgb", [245, 245, 245]),
}),
);
lidar.position.y = rig.nominalSensorHeightM;
content.add(lidar);
const grid = new THREE.GridHelper(
Math.max(20, corridor.forwardLengthM * 2.5),
40,
tokenColor(host, "--nodedc-text-muted", [96, 99, 106]),
tokenColor(host, "--nodedc-glass-outline", [48, 50, 56]),
);
const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material];
gridMaterials.forEach((material) => {
material.transparent = true;
material.opacity = 0.15;
material.depthWrite = false;
});
content.add(grid);
}, [corridor, rig]);
const resetView = () => {
const camera = cameraRef.current;
const controls = controlsRef.current;
if (!camera || !controls) return;
controls.target.set(corridor.forwardLengthM * 0.35, 0.6, 0);
if (mode === "plan") {
camera.position.set(corridor.forwardLengthM * 0.35, 15, 0.001);
camera.up.set(0, 0, -1);
} else {
camera.position.set(-4.5, 4.8, 8.5);
camera.up.set(0, 1, 0);
}
camera.updateProjectionMatrix();
controls.update();
};
useEffect(resetView, [corridor.forwardLengthM, mode]);
useImperativeHandle(ref, () => ({ resetView }));
const semanticLegendEntries = (() => {
if (
!showCurrentIncrement
|| !pointSemanticClassIds
|| pointSemanticClassIds.length !== pointCloudBodyXyzM.length
|| !semanticClasses
|| !semanticPalette
) return [];
const presentIds = new Set(pointSemanticClassIds.filter((item): item is number => item !== null));
const classesById = new Map(semanticClasses.map((item) => [item.id, item]));
return semanticPalette.flatMap((entry) => {
const semanticClass = classesById.get(entry.classId);
if (!semanticClass || !presentIds.has(entry.classId) || entry.color.kind === "transparent") return [];
return [{
id: entry.classId,
label: semanticClass.label,
cssColor: recordedEvidenceSemanticCssColor(entry.color),
}];
});
})();
const metricLegendEntries = laboratoryMetricLegendEntries({
pointCloudCount: pointSemanticClassIds?.every((item) => item !== null)
? 0
: pointCloudBodyXyzM.length,
localSurfaceCount: localSurfaceBodyXyzM.length,
obstacles,
showCurrentIncrement,
showLocalSurface,
showRollingMap,
showLowStep,
});
const classifiedLegendEntries = (() => {
if (!showClassifiedCells || !classifiedCells.length) return [];
const states = new Set(classifiedCells.map((cell) => cell.state));
return [
states.has("ground-support")
? { id: "ground-support" as const, label: "Ground support" }
: null,
states.has("nonground-occupied")
? { id: "nonground-occupied" as const, label: "Non-ground occupied" }
: null,
states.has("unknown-rejected")
? { id: "unknown-rejected" as const, label: "Unknown / rejected" }
: null,
states.has("unobserved")
? { id: "unobserved" as const, label: "Unobserved" }
: null,
]
.filter((entry): entry is NonNullable<typeof entry> => entry !== null)
.filter((entry) => !semanticLegendEntries.some(
(semanticEntry) => semanticEntry.label === entry.label,
));
})();
return (
<div className="laboratory-metric-evidence-scene">
<div ref={hostRef} className="laboratory-metric-evidence-scene__viewport">
{renderError ? <p>{renderError}</p> : null}
</div>
<div className="laboratory-metric-evidence-scene__legend">
{metricLegendEntries.map((entry) => (
<span key={entry.id} data-decision={entry.id}>{entry.label}</span>
))}
{classifiedLegendEntries.map((entry) => (
<span key={entry.id} data-decision={entry.id}>{entry.label}</span>
))}
{semanticLegendEntries.map((entry) => (
<span
key={entry.id}
data-decision="semantic"
style={{ "--laboratory-metric-legend-color": entry.cssColor } as CSSProperties}
>
{entry.label}
</span>
))}
</div>
</div>
);
});
@@ -0,0 +1,366 @@
import { useId, useState, type ReactNode } from "react";
import { Icon, IconButton, Select, StatusBadge } from "@nodedc/ui-react";
export interface LaboratoryOption<T extends string> {
id: T;
label: string;
status?: "progress" | "retained" | "failed" | "unreviewed";
}
export type LaboratoryExecutionClass =
| "deterministic"
| "ai-inference"
| "hybrid";
export type LaboratoryMethodCompleteness = "complete" | "legacy-partial";
export type LaboratoryEvidenceKind = "recorded-replay" | "diagnostic-model";
export interface LaboratoryMethodComponent {
kind: "source" | "tool" | "model" | "algorithm" | "runtime";
name: string;
version: string;
role: string;
identitySha256: string | null;
}
export interface LaboratoryMethod {
completeness: LaboratoryMethodCompleteness;
executionClass: LaboratoryExecutionClass;
pipelineId: string;
components: readonly LaboratoryMethodComponent[];
}
export interface LaboratoryBrief {
question: string;
approach: string;
principalResult: string;
limitation: string;
}
export interface LaboratoryResultMetric {
label: string;
value: string;
hint: string;
}
export interface LaboratoryResultConclusion {
proved: string;
notProved: string;
decision: string;
}
export const LABORATORY_REPORT_TEMPLATE_VERSION =
"missioncore.laboratory-report/v1";
const EXECUTION_LABELS: Record<LaboratoryExecutionClass, string> = {
deterministic: "Детерминированный",
"ai-inference": "AI inference",
hybrid: "Гибридный",
};
const COMPONENT_LABELS: Record<LaboratoryMethodComponent["kind"], string> = {
source: "Источник",
tool: "Инструмент",
model: "Модель",
algorithm: "Алгоритм",
runtime: "Runtime",
};
export function LaboratorySelector<T extends string>({
eyebrow,
title,
description,
label,
value,
options,
disabled = false,
searchable = false,
onChange,
}: {
eyebrow: string;
title: string;
description: string;
label: string;
value: T;
options: readonly LaboratoryOption<T>[];
disabled?: boolean;
searchable?: boolean;
onChange: (value: T) => void;
}) {
return (
<section className="laboratory-selector">
<div>
<span className="section-eyebrow">{eyebrow}</span>
<h2>{title}</h2>
<p>{description}</p>
</div>
<div className="laboratory-selector__control">
<span>{label}</span>
<Select
label={`Выбрать: ${label}`}
value={value}
options={options.map((option) => ({
value: option.id,
label: option.label,
icon: option.status ? (
<span
className="laboratory-status-dot"
data-status={option.status}
aria-label={option.status === "progress"
? "Есть подтверждённый прогресс"
: option.status === "retained"
? "Сохранено для сравнения"
: option.status === "failed"
? "Gate не пройден"
: "Требует классификации"}
>
</span>
) : undefined,
}))}
variant="split"
menuWidth="anchor"
disabled={disabled}
searchable={searchable}
searchPlaceholder={`Найти: ${label.toLocaleLowerCase("ru-RU")}`}
onChange={(next) => onChange(next)}
/>
</div>
</section>
);
}
export function LaboratorySummary({
title,
description,
status,
statusTone = "neutral",
facts,
brief,
method,
}: {
title: string;
description: string;
status: string;
statusTone?: "neutral" | "success" | "warning" | "danger" | "accent";
facts: readonly { label: string; value: string }[];
brief: LaboratoryBrief;
method?: LaboratoryMethod | null;
}) {
const methodComplete = method?.completeness === "complete";
const [expanded, setExpanded] = useState(false);
const detailsId = useId();
return (
<section className="laboratory-summary" data-expanded={expanded ? "true" : undefined}>
<header>
<div className="laboratory-summary__heading">
<span className="section-eyebrow">ЛАБОРАТОРНАЯ РАБОТА</span>
<h2>{title}</h2>
</div>
<div className="laboratory-summary__actions">
<StatusBadge tone={statusTone}>{status}</StatusBadge>
<IconButton
label={expanded ? "Свернуть подробности лабораторной работы" : "Раскрыть подробности лабораторной работы"}
aria-expanded={expanded}
aria-controls={detailsId}
onClick={() => setExpanded((current) => !current)}
>
<span className="laboratory-summary__toggle-glyph" aria-hidden="true">
<Icon name="chevron-down" />
</span>
</IconButton>
</div>
</header>
<div id={detailsId} className="laboratory-summary__details" hidden={!expanded}>
<p className="laboratory-summary__description">{description}</p>
<dl className="laboratory-summary__facts">
{facts.map((fact) => (
<div key={fact.label}>
<dt>{fact.label}</dt>
<dd>{fact.value}</dd>
</div>
))}
</dl>
<dl className="laboratory-summary__brief">
<div>
<dt>Задача</dt>
<dd>{brief.question}</dd>
</div>
<div>
<dt>Как проверяли</dt>
<dd>{brief.approach}</dd>
</div>
<div>
<dt>Главный результат</dt>
<dd>{brief.principalResult}</dd>
</div>
<div>
<dt>Ограничение</dt>
<dd>{brief.limitation}</dd>
</div>
</dl>
{method ? (
<div className="laboratory-summary__method">
<header>
<div>
<span className="section-eyebrow">МЕТОД</span>
<strong>{method.pipelineId}</strong>
</div>
<small>
{EXECUTION_LABELS[method.executionClass]}
{" · "}
{methodComplete ? "полная идентичность" : "legacy · частично"}
</small>
</header>
<dl className="laboratory-summary__components">
{method.components.map((component, index) => (
<div key={`${component.kind}:${component.name}:${index}`}>
<dt>{COMPONENT_LABELS[component.kind]}</dt>
<dd>
<strong>{component.name}</strong>
<small>
{component.role}
{" · "}
{component.version}
{component.identitySha256
? ` · ${component.identitySha256.slice(0, 12)}`
: ""}
</small>
</dd>
</div>
))}
</dl>
</div>
) : null}
</div>
</section>
);
}
export function LaboratoryEvidence({
eyebrow,
title,
kind,
resizable = false,
children,
}: {
eyebrow: string;
title: string;
kind: LaboratoryEvidenceKind;
resizable?: boolean;
children: ReactNode;
}) {
return (
<section
className="lab-result-surface"
data-evidence-kind={kind}
data-resizable={resizable ? "true" : undefined}
>
<header>
<span className="section-eyebrow">{eyebrow}</span>
<strong>{title}</strong>
</header>
<div className="laboratory-evidence-frame">
{children}
</div>
</section>
);
}
export function LaboratoryConclusion({
proved,
notProved,
decision,
}: {
proved: string;
notProved: string;
decision: string;
}) {
return (
<dl className="laboratory-result-conclusion">
<div>
<dt>Доказано</dt>
<dd>{proved}</dd>
</div>
<div>
<dt>Не доказано</dt>
<dd>{notProved}</dd>
</div>
<div>
<dt>Решение</dt>
<dd>{decision}</dd>
</div>
</dl>
);
}
export function LaboratoryMetricGrid({
metrics,
}: {
metrics: readonly LaboratoryResultMetric[];
}) {
return (
<div className="laboratory-result-metrics">
{metrics.map((metric) => (
<div key={metric.label}>
<span>{metric.label}</span>
<strong>{metric.value}</strong>
<small>{metric.hint}</small>
</div>
))}
</div>
);
}
export function LaboratoryResultSummary({
title,
status,
statusTone = "neutral",
metrics,
conclusion,
}: {
title: string;
status: string;
statusTone?: "neutral" | "success" | "warning" | "danger" | "accent";
metrics: readonly LaboratoryResultMetric[];
conclusion: LaboratoryResultConclusion;
}) {
return (
<section className="laboratory-result-summary">
<header>
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
<h2>{title}</h2>
</div>
<StatusBadge tone={statusTone}>{status}</StatusBadge>
</header>
<LaboratoryMetricGrid metrics={metrics} />
<LaboratoryConclusion {...conclusion} />
</section>
);
}
export function LaboratoryWorkTemplate({
summary,
evidence,
result = null,
details = null,
}: {
summary: ReactNode;
evidence: ReactNode;
result?: ReactNode;
details?: ReactNode;
}) {
return (
<div
className="laboratory-work-template"
data-template-contract={LABORATORY_REPORT_TEMPLATE_VERSION}
>
{summary}
{evidence}
{result}
{details}
</div>
);
}
@@ -0,0 +1,211 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
type RefObject,
} from "react";
import { SplitPane } from "@nodedc/ui-react";
import {
RecordedFmp4Player,
type RecordedObservationPlayback,
} from "../RecordedFmp4Player";
import { ObservationTimeline } from "../ObservationTimeline";
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
export const LABORATORY_RECORDED_CLIP_VIEWER_CONTRACT =
"missioncore.laboratory-recorded-clip-viewer/v1" as const;
export interface LaboratoryRecordedClipFrame {
sequence: number;
sourceTimeNs: number;
}
export function nearestLaboratoryRecordedClipFrame(
frames: readonly LaboratoryRecordedClipFrame[],
sourceTimeNs: number,
): LaboratoryRecordedClipFrame | null {
if (!frames.length || !Number.isFinite(sourceTimeNs)) return null;
let left = 0;
let right = frames.length - 1;
while (left < right) {
const middle = Math.floor((left + right) / 2);
if (frames[middle]!.sourceTimeNs < sourceTimeNs) left = middle + 1;
else right = middle;
}
const next = frames[left]!;
const previous = frames[Math.max(0, left - 1)]!;
return Math.abs(previous.sourceTimeNs - sourceTimeNs)
<= Math.abs(next.sourceTimeNs - sourceTimeNs)
? previous
: next;
}
export function laboratoryRecordedClipEndExclusiveNs(
frames: readonly LaboratoryRecordedClipFrame[],
): number | null {
const last = frames.at(-1);
if (!last) return null;
const deltas = frames.slice(1).flatMap((frame, index) => {
const delta = frame.sourceTimeNs - frames[index]!.sourceTimeNs;
return Number.isSafeInteger(delta) && delta > 0 ? [delta] : [];
}).sort((left, right) => left - right);
const typicalDelta = deltas.length
? deltas[Math.floor(deltas.length / 2)]!
: 100_000_000;
return last.sourceTimeNs + typicalDelta;
}
export function LaboratoryRecordedClipPlayer({
source,
segmentCount,
frames,
sequence,
playing,
playbackRate,
cameraPresentation,
continuousPlayback,
sourceCount,
cameraRef,
cameraOverlay,
alternativeScene,
onSequenceChange,
onPlayingChange,
onPlaybackRateChange,
}: {
source: ObservationSourceDescriptor;
segmentCount: number;
frames: readonly LaboratoryRecordedClipFrame[];
sequence: number;
playing: boolean;
playbackRate: number;
cameraPresentation: "primary" | "companion" | "hidden";
continuousPlayback: boolean;
sourceCount: number;
cameraRef?: RefObject<HTMLDivElement | null>;
cameraOverlay?: ReactNode;
alternativeScene?: ReactNode;
onSequenceChange: (sequence: number) => void;
onPlayingChange: (playing: boolean) => void;
onPlaybackRateChange: (rate: number) => void;
}) {
const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
const lastEmittedSequenceRef = useRef(sequence);
lastEmittedSequenceRef.current = sequence;
const frame = useMemo(
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
[frames, sequence],
);
const endExclusiveNs = useMemo(
() => laboratoryRecordedClipEndExclusiveNs(frames),
[frames],
);
const playback = useMemo<RecordedObservationPlayback | null>(() => frame ? ({
currentSeconds: frame.sourceTimeNs / 1_000_000_000,
playing: continuousPlayback && playing,
rate: playbackRate,
}) : null, [continuousPlayback, frame, playbackRate, playing]);
useEffect(() => {
if (!continuousPlayback && playing) onPlayingChange(false);
}, [continuousPlayback, onPlayingChange, playing]);
const emitSequence = useCallback((nextSequence: number) => {
if (lastEmittedSequenceRef.current === nextSequence) return;
lastEmittedSequenceRef.current = nextSequence;
onSequenceChange(nextSequence);
}, [onSequenceChange]);
const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => {
const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000);
const first = frames[0];
if (!first || endExclusiveNs === null) return;
if (sourceTimeNs >= endExclusiveNs) {
emitSequence(first.sequence);
return;
}
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
if (nearest) emitSequence(nearest.sequence);
}, [emitSequence, endExclusiveNs, frames]);
const timelineStart = frames[0]?.sourceTimeNs ?? 0;
const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
const companionVisible = cameraPresentation === "companion";
const spatialSize = companionVisible
? companionSpatialSize
: cameraPresentation === "primary" ? 0 : 100;
const spatialPane = (
<div
className="laboratory-recorded-clip-player__spatial"
aria-hidden={cameraPresentation === "primary"}
>
{cameraPresentation !== "primary" ? alternativeScene : null}
</div>
);
const cameraPane = (
<div
ref={cameraRef}
className="laboratory-recorded-clip-player__camera"
aria-hidden={cameraPresentation === "hidden"}
>
{playback ? (
<RecordedFmp4Player
source={source}
playback={playback}
interactive={false}
prepare
segmentSequence={frame?.sequence}
segmentCount={segmentCount}
admissionKey={`${source.id}:${source.delivery?.id ?? "recorded"}`}
onPlaybackChange={handlePlaybackChange}
onPlayingRejected={() => onPlayingChange(false)}
/>
) : null}
{cameraPresentation !== "hidden" ? cameraOverlay : null}
</div>
);
return (
<div
className="laboratory-recorded-clip-player"
data-contract={LABORATORY_RECORDED_CLIP_VIEWER_CONTRACT}
data-camera-presentation={cameraPresentation}
>
<div className="laboratory-recorded-clip-player__stage">
<SplitPane
className="laboratory-recorded-clip-player__split"
primary={spatialPane}
secondary={cameraPane}
primarySize={spatialSize}
onPrimarySizeChange={setCompanionSpatialSize}
orientation="vertical"
minPrimarySize={companionVisible ? 24 : 0}
minSecondarySize={companionVisible ? 24 : 0}
resizable={companionVisible}
separatorLabel="Изменить размер 3D/ПЛАН и правой камеры"
/>
</div>
<ObservationTimeline
className="laboratory-recorded-clip-player__timeline"
active
sourceCount={sourceCount}
mode="recorded"
seekable
synchronization="frame-accurate"
rangeNs={{ min: timelineStart, max: Math.max(timelineEnd, timelineStart + 1) }}
currentNs={frame?.sourceTimeNs ?? timelineStart}
playing={continuousPlayback && playing}
playbackRate={continuousPlayback ? playbackRate : undefined}
onPlaybackRateChange={continuousPlayback ? onPlaybackRateChange : undefined}
onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
onSeek={(timeNs) => {
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
if (nearest) emitSequence(nearest.sequence);
}}
showJumpToEnd={false}
/>
</div>
);
}
@@ -0,0 +1,111 @@
import {
useEffect,
useRef,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
const FOCUSABLE = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
function keepFocusInside(
event: ReactKeyboardEvent<HTMLElement>,
frame: HTMLElement,
): void {
if (event.key !== "Tab") return;
const focusable = Array.from(frame.querySelectorAll<HTMLElement>(FOCUSABLE));
if (!focusable.length) {
event.preventDefault();
frame.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
export function LaboratoryReviewWorkspaceFrame({
ariaLabel,
toolbar,
stage,
inspector,
overlays,
interactionEnabled = true,
returnFocusTarget,
onClose,
}: {
ariaLabel: string;
toolbar: ReactNode;
stage: ReactNode;
inspector?: ReactNode;
overlays?: ReactNode;
interactionEnabled?: boolean;
returnFocusTarget?: HTMLElement | null;
onClose: () => void;
}) {
const frameRef = useRef<HTMLElement>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (typeof document === "undefined") return;
const previousFocus = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const animationFrame = window.requestAnimationFrame(() => {
const firstFocusable = frameRef.current?.querySelector<HTMLElement>(FOCUSABLE);
(firstFocusable ?? frameRef.current)?.focus();
});
return () => {
window.cancelAnimationFrame(animationFrame);
document.body.style.overflow = previousOverflow;
const target = returnFocusTarget?.isConnected ? returnFocusTarget : previousFocus;
window.requestAnimationFrame(() => target?.focus());
};
}, [returnFocusTarget]);
if (typeof document === "undefined") return null;
return createPortal(
<section
ref={frameRef}
className="laboratory-review-workspace"
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
data-has-inspector={inspector ? "true" : undefined}
tabIndex={-1}
onKeyDown={(event) => {
if (!interactionEnabled) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onCloseRef.current();
return;
}
keepFocusInside(event, event.currentTarget);
}}
>
<header className="laboratory-review-workspace__toolbar">{toolbar}</header>
<div className="laboratory-review-workspace__stage">{stage}</div>
{inspector ? <footer className="laboratory-review-workspace__inspector">{inspector}</footer> : null}
{overlays}
</section>,
document.body,
);
}
@@ -0,0 +1,110 @@
import { useEffect, useRef } from "react";
export type RecordedEvidenceBoxTone =
| "accent"
| "danger"
| "success"
| "warning"
| "neutral";
export interface RecordedEvidenceBox {
boxXyxy: readonly [number, number, number, number];
label: string;
tone: RecordedEvidenceBoxTone;
dashed?: boolean;
}
function rgba(
host: HTMLElement,
token: string,
fallback: readonly [number, number, number],
alpha = 1,
): string {
const channels = getComputedStyle(host)
.getPropertyValue(token)
.trim()
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number);
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
function toneColor(host: HTMLElement, tone: RecordedEvidenceBoxTone): string {
if (tone === "danger") return rgba(host, "--nodedc-danger-rgb", [255, 104, 112]);
if (tone === "success") return rgba(host, "--nodedc-success-rgb", [181, 255, 90]);
if (tone === "warning") return rgba(host, "--nodedc-warning-rgb", [255, 197, 92]);
if (tone === "neutral") return rgba(host, "--nodedc-foreground-rgb", [245, 245, 245], 0.7);
return rgba(host, "--nodedc-accent-rgb", [232, 56, 126]);
}
export function RecordedEvidenceBoxOverlay({
imageWidth,
imageHeight,
boxes,
ariaLabel,
}: {
imageWidth: number;
imageHeight: number;
boxes: readonly RecordedEvidenceBox[];
ariaLabel: string;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
const host = canvas?.parentElement;
if (!host || !canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const render = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, width, height);
const scale = Math.min(width / imageWidth, height / imageHeight);
const drawWidth = imageWidth * scale;
const drawHeight = imageHeight * scale;
const offsetX = (width - drawWidth) / 2;
const offsetY = (height - drawHeight) / 2;
for (const item of boxes) {
const [left, top, right, bottom] = item.boxXyxy;
const x = offsetX + left * scale;
const y = offsetY + top * scale;
const boxWidth = (right - left) * scale;
const boxHeight = (bottom - top) * scale;
const stroke = toneColor(host, item.tone);
context.strokeStyle = stroke;
context.lineWidth = Math.max(1.5, 2 * scale);
context.setLineDash(item.dashed ? [5, 4] : []);
context.strokeRect(x, y, boxWidth, boxHeight);
context.setLineDash([]);
const fontSize = Math.max(9, 10 * scale);
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
const labelWidth = Math.min(drawWidth, context.measureText(item.label).width + 8);
const labelHeight = fontSize + 6;
const labelX = Math.min(offsetX + drawWidth - labelWidth, Math.max(offsetX, x));
const labelY = Math.max(offsetY, y - labelHeight);
context.fillStyle = rgba(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
context.fillRect(labelX, labelY, labelWidth, labelHeight);
context.fillStyle = stroke;
context.fillText(item.label, labelX + 4, labelY + fontSize + 1, labelWidth - 8);
}
};
const observer = new ResizeObserver(render);
observer.observe(host);
render();
return () => observer.disconnect();
}, [boxes, imageHeight, imageWidth]);
return <canvas ref={canvasRef} role="img" aria-label={ariaLabel} />;
}
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { Icon } from "@nodedc/ui-react";
import {
RecordedEvidenceBoxOverlay,
type RecordedEvidenceBox,
} from "./RecordedEvidenceBoxOverlay";
import {
RecordedEvidenceSemanticMaskOverlay,
type RecordedEvidenceSemanticOverlay,
} from "./RecordedEvidenceSemanticMaskOverlay";
import {
RecordedEvidencePointCloudOverlay,
type RecordedEvidencePointCloudOverlayData,
} from "./RecordedEvidencePointCloudOverlay";
export function RecordedEvidenceImageScene({
src,
imageWidth,
imageHeight,
boxes,
semanticOverlay,
pointCloudOverlay,
ariaLabel,
}: {
src: string;
imageWidth: number;
imageHeight: number;
boxes: readonly RecordedEvidenceBox[];
semanticOverlay?: RecordedEvidenceSemanticOverlay;
pointCloudOverlay?: RecordedEvidencePointCloudOverlayData;
ariaLabel: string;
}) {
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => setState("loading"), [src]);
return (
<div className="recorded-evidence-image-scene">
<img
src={src}
alt=""
draggable={false}
onLoad={() => setState("ready")}
onError={() => setState("error")}
/>
{state === "ready" ? (
<>
{semanticOverlay ? (
<RecordedEvidenceSemanticMaskOverlay
{...semanticOverlay}
imageWidth={imageWidth}
imageHeight={imageHeight}
/>
) : null}
{pointCloudOverlay ? (
<RecordedEvidencePointCloudOverlay
imageWidth={imageWidth}
imageHeight={imageHeight}
overlay={pointCloudOverlay}
/>
) : null}
<RecordedEvidenceBoxOverlay
imageWidth={imageWidth}
imageHeight={imageHeight}
boxes={boxes}
ariaLabel={ariaLabel}
/>
</>
) : (
<div className="l3-visual-audit__state" role="status">
{state === "loading" ? (
<span className="busy-indicator" aria-hidden="true" />
) : (
<Icon name="alert" size={18} />
)}
<span>
{state === "loading"
? "Декодируем один точный CAMERA-кадр"
: "Точный CAMERA-кадр недоступен."}
</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,91 @@
import { useEffect, useRef } from "react";
export type RecordedEvidenceProjectedPoint = readonly [number, number, number];
export interface RecordedEvidencePointCloudOverlayData {
pointsXyd: readonly RecordedEvidenceProjectedPoint[];
sourcePointCount: number;
projectedPointCount: number;
projection:
| "factory-kb4-exact"
| "factory-kb4-causal-registered-accumulation";
ariaLabel: string;
}
const DEPTH_BUCKETS = 64;
function depthBucket(depthM: number): number {
const normalized = Math.max(0, Math.min(1, (depthM - 0.5) / 24));
return Math.round(normalized * (DEPTH_BUCKETS - 1));
}
function depthColor(bucket: number): string {
const hue = 18 + bucket / (DEPTH_BUCKETS - 1) * 190;
return `hsla(${hue}, 96%, 62%, 0.86)`;
}
export function RecordedEvidencePointCloudOverlay({
imageWidth,
imageHeight,
overlay,
}: {
imageWidth: number;
imageHeight: number;
overlay: RecordedEvidencePointCloudOverlayData;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
const host = canvas?.parentElement;
if (!canvas || !host) return;
const context = canvas.getContext("2d");
if (!context) return;
const render = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, width, height);
const scale = Math.min(width / imageWidth, height / imageHeight);
const offsetX = (width - imageWidth * scale) / 2;
const offsetY = (height - imageHeight * scale) / 2;
const radius = Math.max(0.8, Math.min(2.2, scale * 1.45));
const buckets = Array.from(
{ length: DEPTH_BUCKETS },
(): [number, number][] => [],
);
for (const [imageX, imageY, depthM] of overlay.pointsXyd) {
const bucket = depthBucket(depthM);
buckets[bucket]!.push([
offsetX + imageX * scale,
offsetY + imageY * scale,
]);
}
for (let bucket = 0; bucket < buckets.length; bucket += 1) {
const points = buckets[bucket];
if (!points?.length) continue;
context.beginPath();
for (const [x, y] of points) {
context.moveTo(x + radius, y);
context.arc(x, y, radius, 0, Math.PI * 2);
}
context.fillStyle = depthColor(bucket);
context.fill();
}
};
const observer = new ResizeObserver(render);
observer.observe(host);
render();
return () => observer.disconnect();
}, [imageHeight, imageWidth, overlay]);
return <canvas ref={canvasRef} role="img" aria-label={overlay.ariaLabel} />;
}
@@ -0,0 +1,647 @@
import { useEffect, useRef, useState } from "react";
export interface RecordedEvidenceSemanticClass {
id: number;
label: string;
}
export type RecordedEvidenceSemanticToken =
| "--nodedc-accent-rgb"
| "--nodedc-danger-rgb"
| "--nodedc-foreground-rgb"
| "--nodedc-success-rgb"
| "--nodedc-text-muted"
| "--nodedc-warning-rgb";
export type RecordedEvidenceSemanticColor =
| {
kind: "token";
token: RecordedEvidenceSemanticToken;
}
| {
kind: "diagnostic";
rgb: readonly [number, number, number];
}
| {
kind: "transparent";
};
export interface RecordedEvidenceSemanticPaletteEntry {
classId: number;
color: RecordedEvidenceSemanticColor;
opacity?: number;
}
export interface RecordedEvidenceSemanticOverlay {
src: string;
prefetchSrcs?: readonly string[];
classes: readonly RecordedEvidenceSemanticClass[];
palette: readonly RecordedEvidenceSemanticPaletteEntry[];
opacity?: number;
ariaLabel: string;
}
interface DecodedSemanticMask {
key: string;
width: number;
height: number;
source: ImageBitmap | HTMLImageElement;
release: () => void;
}
interface PendingSemanticMask {
controller: AbortController;
subscribers: number;
promise: Promise<DecodedSemanticMask>;
}
const MASK_CACHE_LIMIT = 20;
const MASK_PREFETCH_LIMIT = 12;
const decodedMaskCache = new Map<string, DecodedSemanticMask>();
const pendingMaskCache = new Map<string, PendingSemanticMask>();
const TOKEN_FALLBACKS: Record<RecordedEvidenceSemanticToken, readonly [number, number, number]> = {
"--nodedc-accent-rgb": [232, 56, 126],
"--nodedc-danger-rgb": [255, 104, 112],
"--nodedc-foreground-rgb": [245, 245, 245],
"--nodedc-success-rgb": [181, 255, 90],
"--nodedc-text-muted": [147, 151, 159],
"--nodedc-warning-rgb": [255, 197, 92],
};
function clampChannel(value: number): number {
return Math.max(0, Math.min(255, Math.round(value)));
}
function clampOpacity(value: number | undefined, fallback: number): number {
return Math.max(0, Math.min(1, Number.isFinite(value) ? Number(value) : fallback));
}
function semanticMaskKey(src: string, width: number, height: number): string {
return `${width}x${height}:${src}`;
}
function rememberMask(mask: DecodedSemanticMask): void {
const previous = decodedMaskCache.get(mask.key);
if (previous && previous !== mask) previous.release();
decodedMaskCache.delete(mask.key);
decodedMaskCache.set(mask.key, mask);
while (decodedMaskCache.size > MASK_CACHE_LIMIT) {
const oldest = decodedMaskCache.keys().next().value;
if (typeof oldest !== "string") break;
const evicted = decodedMaskCache.get(oldest);
decodedMaskCache.delete(oldest);
evicted?.release();
}
}
async function imageSourceFromBlob(
blob: Blob,
signal: AbortSignal,
): Promise<{ source: ImageBitmap | HTMLImageElement; width: number; height: number; release: () => void }> {
if (typeof createImageBitmap === "function") {
const bitmap = await createImageBitmap(blob, {
colorSpaceConversion: "none",
premultiplyAlpha: "none",
});
if (signal.aborted) {
bitmap.close();
throw new DOMException("Aborted", "AbortError");
}
return {
source: bitmap,
width: bitmap.width,
height: bitmap.height,
release: () => bitmap.close(),
};
}
const objectUrl = URL.createObjectURL(blob);
const image = new Image();
image.decoding = "async";
try {
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
image.removeEventListener("load", onLoad);
image.removeEventListener("error", onError);
signal.removeEventListener("abort", onAbort);
};
const onLoad = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error("Semantic mask image decode failed"));
};
const onAbort = () => {
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
image.addEventListener("load", onLoad, { once: true });
image.addEventListener("error", onError, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
image.src = objectUrl;
});
return {
source: image,
width: image.naturalWidth,
height: image.naturalHeight,
release: () => URL.revokeObjectURL(objectUrl),
};
} catch (error) {
URL.revokeObjectURL(objectUrl);
throw error;
}
}
async function decodeSemanticMask(
key: string,
src: string,
expectedWidth: number,
expectedHeight: number,
signal: AbortSignal,
): Promise<DecodedSemanticMask> {
const response = await fetch(src, { cache: "force-cache", signal });
if (!response.ok) throw new Error(`Semantic mask request failed: ${response.status}`);
const blob = await response.blob();
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
const decoded = await imageSourceFromBlob(blob, signal);
if (decoded.width !== expectedWidth || decoded.height !== expectedHeight) {
decoded.release();
throw new Error(
`Semantic mask dimensions ${decoded.width}x${decoded.height} do not match ${expectedWidth}x${expectedHeight}`,
);
}
return {
key,
width: decoded.width,
height: decoded.height,
source: decoded.source,
release: decoded.release,
};
}
function subscribeToSemanticMask(
src: string,
width: number,
height: number,
): { promise: Promise<DecodedSemanticMask>; release: () => void } {
const key = semanticMaskKey(src, width, height);
const cached = decodedMaskCache.get(key);
if (cached) {
decodedMaskCache.delete(key);
decodedMaskCache.set(key, cached);
return { promise: Promise.resolve(cached), release: () => undefined };
}
let pending = pendingMaskCache.get(key);
if (!pending) {
const controller = new AbortController();
let next: PendingSemanticMask;
const promise = decodeSemanticMask(key, src, width, height, controller.signal)
.then((mask) => {
rememberMask(mask);
return mask;
})
.finally(() => {
if (pendingMaskCache.get(key) === next) pendingMaskCache.delete(key);
});
next = { controller, subscribers: 0, promise };
pendingMaskCache.set(key, next);
pending = next;
}
pending.subscribers += 1;
let released = false;
return {
promise: pending.promise,
release: () => {
if (released) return;
released = true;
pending!.subscribers -= 1;
if (pending!.subscribers > 0 || decodedMaskCache.has(key)) return;
pending!.controller.abort();
if (pendingMaskCache.get(key) === pending) pendingMaskCache.delete(key);
},
};
}
export function recordedEvidenceSemanticCssColor(
color: RecordedEvidenceSemanticColor,
): string {
if (color.kind === "transparent") return "transparent";
if (color.kind === "token") return `rgb(var(${color.token}))`;
const [red, green, blue] = color.rgb.map(clampChannel);
return `rgb(${red} ${green} ${blue})`;
}
export function resolveRecordedEvidenceSemanticRgb(
host: HTMLElement,
color: RecordedEvidenceSemanticColor,
): readonly [number, number, number] | null {
if (color.kind === "transparent") return null;
if (color.kind === "diagnostic") return color.rgb.map(clampChannel) as [number, number, number];
const channels = getComputedStyle(host)
.getPropertyValue(color.token)
.trim()
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number);
return channels?.length === 3
? channels.map(clampChannel) as [number, number, number]
: TOKEN_FALLBACKS[color.token];
}
interface SemanticMaskRenderer {
clear: (width: number, height: number, pixelRatio: number) => void;
render: (
mask: DecodedSemanticMask,
palette: Uint8Array,
width: number,
height: number,
pixelRatio: number,
) => void;
dispose: () => void;
}
function compileSemanticShader(
gl: WebGLRenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
gl.deleteShader(shader);
return null;
}
function createSemanticMaskRenderer(canvas: HTMLCanvasElement): SemanticMaskRenderer | null {
const gl = canvas.getContext("webgl", {
alpha: true,
antialias: false,
depth: false,
premultipliedAlpha: false,
preserveDrawingBuffer: true,
});
if (!gl) return null;
const vertexShader = compileSemanticShader(gl, gl.VERTEX_SHADER, `
attribute vec2 a_position;
attribute vec2 a_uv;
varying vec2 v_uv;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_uv = a_uv;
}
`);
const fragmentShader = compileSemanticShader(gl, gl.FRAGMENT_SHADER, `
precision mediump float;
uniform sampler2D u_mask;
uniform sampler2D u_palette;
varying vec2 v_uv;
void main() {
// DOM image sources use a top-left origin while WebGL texture coordinates
// use a bottom-left origin. UNPACK_FLIP_Y_WEBGL is ignored for
// ImageBitmap uploads, so keep upload semantics stable and flip V here.
vec2 mask_uv = vec2(v_uv.x, 1.0 - v_uv.y);
float class_id = floor(texture2D(u_mask, mask_uv).r * 255.0 + 0.5);
gl_FragColor = texture2D(u_palette, vec2((class_id + 0.5) / 256.0, 0.5));
}
`);
if (!vertexShader || !fragmentShader) {
if (vertexShader) gl.deleteShader(vertexShader);
if (fragmentShader) gl.deleteShader(fragmentShader);
return null;
}
const program = gl.createProgram();
if (!program) {
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
return null;
}
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program);
return null;
}
const buffer = gl.createBuffer();
const maskTexture = gl.createTexture();
const paletteTexture = gl.createTexture();
if (!buffer || !maskTexture || !paletteTexture) {
if (buffer) gl.deleteBuffer(buffer);
if (maskTexture) gl.deleteTexture(maskTexture);
if (paletteTexture) gl.deleteTexture(paletteTexture);
gl.deleteProgram(program);
return null;
}
const positionLocation = gl.getAttribLocation(program, "a_position");
const uvLocation = gl.getAttribLocation(program, "a_uv");
const maskLocation = gl.getUniformLocation(program, "u_mask");
const paletteLocation = gl.getUniformLocation(program, "u_palette");
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 0, 0,
1, -1, 1, 0,
-1, 1, 0, 1,
1, 1, 1, 1,
]), gl.STATIC_DRAW);
const resize = (width: number, height: number, pixelRatio: number) => {
const pixelWidth = Math.max(1, Math.round(width * pixelRatio));
const pixelHeight = Math.max(1, Math.round(height * pixelRatio));
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
};
const clear = (width: number, height: number, pixelRatio: number) => {
resize(width, height, pixelRatio);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
};
return {
clear,
render: (mask, palette, width, height, pixelRatio) => {
if (gl.isContextLost()) throw new Error("Semantic WebGL context lost");
clear(width, height, pixelRatio);
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(uvLocation);
gl.vertexAttribPointer(uvLocation, 2, gl.FLOAT, false, 16, 8);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, maskTexture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, mask.source);
gl.uniform1i(maskLocation, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, palette);
gl.uniform1i(paletteLocation, 1);
const scale = Math.min(width / mask.width, height / mask.height);
const drawWidth = mask.width * scale;
const drawHeight = mask.height * scale;
const offsetX = (width - drawWidth) / 2;
const offsetY = (height - drawHeight) / 2;
gl.viewport(
Math.round(offsetX * pixelRatio),
Math.round(offsetY * pixelRatio),
Math.max(1, Math.round(drawWidth * pixelRatio)),
Math.max(1, Math.round(drawHeight * pixelRatio)),
);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
dispose: () => {
gl.deleteTexture(maskTexture);
gl.deleteTexture(paletteTexture);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
},
};
}
function semanticPalettePixels(
host: HTMLElement,
classes: readonly RecordedEvidenceSemanticClass[],
palette: readonly RecordedEvidenceSemanticPaletteEntry[],
opacity: number,
): Uint8Array {
const pixels = new Uint8Array(256 * 4);
const declaredClassIds = new Set(
classes
.map((item) => item.id)
.filter((classId) => Number.isInteger(classId) && classId >= 0 && classId <= 255),
);
for (const entry of palette) {
if (!declaredClassIds.has(entry.classId)) continue;
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
if (!rgb) continue;
const offset = entry.classId * 4;
pixels[offset] = rgb[0];
pixels[offset + 1] = rgb[1];
pixels[offset + 2] = rgb[2];
pixels[offset + 3] = Math.round(
clampOpacity(entry.opacity, 1) * clampOpacity(opacity, 0.46) * 255,
);
}
return pixels;
}
function renderSemanticMask2d(
canvas: HTMLCanvasElement,
mask: DecodedSemanticMask,
palette: Uint8Array,
width: number,
height: number,
pixelRatio: number,
) {
canvas.width = Math.max(1, Math.round(width * pixelRatio));
canvas.height = Math.max(1, Math.round(height * pixelRatio));
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, width, height);
const colorCanvas = document.createElement("canvas");
colorCanvas.width = mask.width;
colorCanvas.height = mask.height;
const colorContext = colorCanvas.getContext("2d", { willReadFrequently: true });
if (!colorContext) return;
colorContext.drawImage(mask.source, 0, 0);
const imageData = colorContext.getImageData(0, 0, mask.width, mask.height);
for (let offset = 0; offset < imageData.data.length; offset += 4) {
const paletteOffset = (imageData.data[offset] ?? 0) * 4;
imageData.data[offset] = palette[paletteOffset] ?? 0;
imageData.data[offset + 1] = palette[paletteOffset + 1] ?? 0;
imageData.data[offset + 2] = palette[paletteOffset + 2] ?? 0;
imageData.data[offset + 3] = palette[paletteOffset + 3] ?? 0;
}
colorContext.putImageData(imageData, 0, 0);
const scale = Math.min(width / mask.width, height / mask.height);
const drawWidth = mask.width * scale;
const drawHeight = mask.height * scale;
context.imageSmoothingEnabled = false;
context.drawImage(
colorCanvas,
(width - drawWidth) / 2,
(height - drawHeight) / 2,
drawWidth,
drawHeight,
);
}
export function RecordedEvidenceSemanticMaskOverlay({
src,
prefetchSrcs = [],
imageWidth,
imageHeight,
classes,
palette,
opacity = 0.46,
ariaLabel,
}: RecordedEvidenceSemanticOverlay & {
imageWidth: number;
imageHeight: number;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const rendererRef = useRef<SemanticMaskRenderer | null | undefined>(undefined);
const lastReadyMaskRef = useRef<{ series: string; mask: DecodedSemanticMask } | null>(null);
const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl");
const [mask, setMask] = useState<DecodedSemanticMask | null>(null);
const [failure, setFailure] = useState<string | null>(null);
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
const prefetchSignature = prefetchSrcs.join("\n");
const series = src.slice(0, Math.max(src.lastIndexOf("/"), 0));
if (lastReadyMaskRef.current?.series !== series) lastReadyMaskRef.current = null;
const exactMask = failure
? null
: mask?.key === expectedKey
? mask
: decodedMaskCache.get(expectedKey) ?? null;
if (exactMask) lastReadyMaskRef.current = { series, mask: exactMask };
const renderMask = exactMask ?? lastReadyMaskRef.current?.mask ?? null;
useEffect(() => {
setFailure(null);
const subscription = subscribeToSemanticMask(src, imageWidth, imageHeight);
let current = true;
void subscription.promise.then((decoded) => {
if (!current || decoded.key !== expectedKey) return;
setMask(decoded);
}).catch((error: unknown) => {
if (!current || (error instanceof DOMException && error.name === "AbortError")) return;
setFailure("Semantic mask не прошла проверку или декодирование.");
});
return () => {
current = false;
subscription.release();
};
}, [expectedKey, imageHeight, imageWidth, src]);
useEffect(() => {
const subscriptions = prefetchSignature
.split("\n")
.filter((candidate) => candidate && candidate !== src)
.slice(0, MASK_PREFETCH_LIMIT)
.map((candidate) => subscribeToSemanticMask(candidate, imageWidth, imageHeight));
for (const subscription of subscriptions) {
void subscription.promise
.catch(() => undefined)
.finally(subscription.release);
}
}, [imageHeight, imageWidth, prefetchSignature, src]);
useEffect(() => () => {
rendererRef.current?.dispose();
rendererRef.current = undefined;
}, [rendererMode]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || rendererMode !== "webgl") return;
const onContextLost = (event: Event) => {
event.preventDefault();
setRendererMode("2d");
};
canvas.addEventListener("webglcontextlost", onContextLost);
return () => canvas.removeEventListener("webglcontextlost", onContextLost);
}, [rendererMode]);
useEffect(() => {
const canvas = canvasRef.current;
const host = canvas?.parentElement;
if (!canvas || !host) return;
const render = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
if (rendererMode === "webgl" && rendererRef.current === undefined) {
rendererRef.current = createSemanticMaskRenderer(canvas);
if (!rendererRef.current) {
setRendererMode("2d");
return;
}
}
const renderer = rendererMode === "webgl" ? rendererRef.current : null;
if (!renderMask) {
if (renderer) {
renderer.clear(width, height, pixelRatio);
} else {
canvas.width = Math.max(1, Math.round(width * pixelRatio));
canvas.height = Math.max(1, Math.round(height * pixelRatio));
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
return;
}
const resolvedPalette = semanticPalettePixels(host, classes, palette, opacity);
if (renderer) {
try {
renderer.render(renderMask, resolvedPalette, width, height, pixelRatio);
} catch {
setRendererMode("2d");
}
} else {
renderSemanticMask2d(
canvas,
renderMask,
resolvedPalette,
width,
height,
pixelRatio,
);
}
};
const observer = new ResizeObserver(render);
observer.observe(host);
render();
return () => observer.disconnect();
}, [classes, expectedKey, imageHeight, imageWidth, opacity, palette, rendererMode, renderMask]);
return (
<>
<canvas
key={rendererMode}
ref={canvasRef}
className="recorded-evidence-semantic-mask-overlay"
role="img"
aria-label={ariaLabel}
aria-busy={!failure && !exactMask}
data-state={failure ? "error" : exactMask ? "ready" : renderMask ? "stale" : "loading"}
data-renderer={rendererMode}
style={{ zIndex: 1 }}
/>
{failure ? (
<div className="recorded-evidence-semantic-mask-overlay__error" role="alert">
{failure}
</div>
) : null}
</>
);
}
@@ -0,0 +1,85 @@
import {
RecordedFmp4Player,
type RecordedObservationPlayback,
} from "../RecordedFmp4Player";
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
import {
RecordedEvidenceBoxOverlay,
type RecordedEvidenceBox,
type RecordedEvidenceBoxTone,
} from "./RecordedEvidenceBoxOverlay";
import {
RecordedEvidenceSemanticMaskOverlay,
type RecordedEvidenceSemanticOverlay,
} from "./RecordedEvidenceSemanticMaskOverlay";
import {
RecordedEvidencePointCloudOverlay,
type RecordedEvidencePointCloudOverlayData,
} from "./RecordedEvidencePointCloudOverlay";
export type { RecordedEvidenceBox, RecordedEvidenceBoxTone };
export function RecordedEvidenceVideoScene({
source,
playback,
imageWidth,
imageHeight,
boxes,
semanticOverlay,
pointCloudOverlay,
ariaLabel,
interactive = true,
segmentSequence,
segmentCount,
onPlaybackChange,
onPlayingRejected,
}: {
source: ObservationSourceDescriptor;
playback: RecordedObservationPlayback;
imageWidth: number;
imageHeight: number;
boxes: readonly RecordedEvidenceBox[];
semanticOverlay?: RecordedEvidenceSemanticOverlay;
pointCloudOverlay?: RecordedEvidencePointCloudOverlayData;
ariaLabel: string;
interactive?: boolean;
segmentSequence?: number;
segmentCount?: number;
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
onPlayingRejected?: () => void;
}) {
return (
<div className="recorded-evidence-video-scene">
<RecordedFmp4Player
source={source}
playback={playback}
interactive={interactive}
prepare
segmentSequence={segmentSequence}
segmentCount={segmentCount}
onPlaybackChange={onPlaybackChange}
onPlayingRejected={onPlayingRejected}
/>
{semanticOverlay ? (
<RecordedEvidenceSemanticMaskOverlay
{...semanticOverlay}
imageWidth={imageWidth}
imageHeight={imageHeight}
/>
) : null}
{pointCloudOverlay ? (
<RecordedEvidencePointCloudOverlay
imageWidth={imageWidth}
imageHeight={imageHeight}
overlay={pointCloudOverlay}
/>
) : null}
<RecordedEvidenceBoxOverlay
imageWidth={imageWidth}
imageHeight={imageHeight}
boxes={boxes}
ariaLabel={ariaLabel}
/>
</div>
);
}
@@ -0,0 +1,41 @@
import { useState, type ReactNode } from "react";
import { Button, Icon } from "@nodedc/ui-react";
import type {
LaboratoryAnnotationAction,
LaboratoryViewAction,
} from "../../workspaces/contracts";
export function useLaboratoryAnnotationHeader(): {
control: ReactNode;
setAction: (action: LaboratoryAnnotationAction | null) => void;
setViewAction: (action: LaboratoryViewAction | null) => void;
} {
const [action, setAction] = useState<LaboratoryAnnotationAction | null>(null);
const [viewAction, setViewAction] = useState<LaboratoryViewAction | null>(null);
return {
setAction,
setViewAction,
control: action || viewAction ? (
<div className="laboratory-header-tools">
{viewAction ? (
<Button shape="pill" variant="secondary" onClick={viewAction.onClick}>
{viewAction.label}
</Button>
) : null}
{action ? (
<Button
size="compact"
shape="pill"
variant="accent"
icon={<Icon name="edit" size={16} />}
disabled={action.disabled}
onClick={action.onClick}
>
{action.label}
</Button>
) : null}
</div>
) : null,
};
}
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { RecordedObservationPlayback } from "../RecordedFmp4Player";
export interface RecordedEvidencePlaybackRange {
startSeconds: number;
endSeconds: number;
}
function validRange(
range: RecordedEvidencePlaybackRange | null,
): range is RecordedEvidencePlaybackRange {
return Boolean(
range
&& Number.isFinite(range.startSeconds)
&& Number.isFinite(range.endSeconds)
&& range.endSeconds > range.startSeconds,
);
}
export function clampRecordedEvidenceSeconds(
seconds: number,
range: RecordedEvidencePlaybackRange,
): number {
return Math.min(range.endSeconds, Math.max(range.startSeconds, seconds));
}
export function advanceRecordedEvidencePlayback(
playback: RecordedObservationPlayback,
elapsedSeconds: number,
range: RecordedEvidencePlaybackRange,
): RecordedObservationPlayback {
if (!playback.playing || !Number.isFinite(elapsedSeconds) || elapsedSeconds <= 0) {
return playback;
}
const next = playback.currentSeconds + elapsedSeconds * (playback.rate ?? 1);
if (next >= range.endSeconds) {
return { ...playback, currentSeconds: range.endSeconds, playing: false };
}
return { ...playback, currentSeconds: clampRecordedEvidenceSeconds(next, range) };
}
export function synchronizeRecordedEvidencePlayback(
current: RecordedObservationPlayback,
observed: RecordedObservationPlayback,
range: RecordedEvidencePlaybackRange,
): RecordedObservationPlayback {
if (!current.playing) {
// A paused transport also covers every explicit operator seek. Native
// callbacks from the previously displayed frame remain stale until the
// controlled video effect has applied that target, so they cannot move the
// shared LAB clock at all while the operator state is paused.
return current;
}
const currentSeconds = clampRecordedEvidenceSeconds(observed.currentSeconds, range);
return {
...current,
currentSeconds,
// The transport button owns play/pause intent. A native frame callback may
// still report the previous media state immediately after an operator
// command, so it must not restart playback. The sealed end is the only
// media-clock transition allowed to stop the shared LAB clock.
playing: currentSeconds >= range.endSeconds ? false : current.playing,
};
}
export function useRecordedEvidencePlayback(
range: RecordedEvidencePlaybackRange | null,
{ clock = "animation" }: { clock?: "animation" | "external" } = {},
) {
const [playback, setPlayback] = useState<RecordedObservationPlayback>({
currentSeconds: 0,
playing: false,
rate: 1,
});
useEffect(() => {
if (!validRange(range)) return;
setPlayback((current) => ({
...current,
currentSeconds: current.currentSeconds === 0
? range.startSeconds
: clampRecordedEvidenceSeconds(current.currentSeconds, range),
}));
}, [range]);
useEffect(() => {
if (!validRange(range) || !playback.playing || clock === "external") return;
let animationFrame = 0;
let previous = performance.now();
const tick = (now: number) => {
const elapsed = Math.max(0, now - previous);
if (elapsed >= 32) {
previous = now;
setPlayback((current) => {
if (!current.playing) return current;
return advanceRecordedEvidencePlayback(current, elapsed / 1_000, range);
});
}
animationFrame = window.requestAnimationFrame(tick);
};
animationFrame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(animationFrame);
}, [clock, playback.playing, range]);
const seek = useCallback((seconds: number, pause = true) => {
if (!validRange(range)) return;
setPlayback((current) => ({
...current,
currentSeconds: clampRecordedEvidenceSeconds(seconds, range),
playing: pause ? false : current.playing,
}));
}, [range]);
const setPlaying = useCallback((playing: boolean) => {
if (!validRange(range)) return;
setPlayback((current) => ({
...current,
currentSeconds: playing && current.currentSeconds >= range.endSeconds
? range.startSeconds
: current.currentSeconds,
playing,
}));
}, [range]);
const setRate = useCallback((rate: number) => {
if (![0.5, 1, 2].includes(rate)) return;
setPlayback((current) => ({ ...current, rate }));
}, []);
const synchronize = useCallback((next: RecordedObservationPlayback) => {
if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return;
setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range));
}, [range]);
return useMemo(() => ({
playback,
seek,
setPlaying,
setRate,
synchronize,
}), [playback, seek, setPlaying, setRate, synchronize]);
}
@@ -0,0 +1,381 @@
import {
Button,
Checker,
ColorField,
ControlRow,
InspectorSelectField,
RangeControl,
StatusBadge,
} from "@nodedc/ui-react";
import type {
MapGhostPinLabelMode,
MapViewDocument,
} from "../../core/map/mapView";
export function GhostPinSettingsPanel({
draft,
updateDraft,
onGenerate,
onClear,
}: {
draft: MapViewDocument;
updateDraft: (
update: (current: MapViewDocument) => MapViewDocument,
) => void;
onGenerate: () => void;
onClear: () => void;
}) {
const configuration = draft.view.ghostPins;
const profile = configuration.presentation;
return (
<div className="mission-map__inspector-controls">
<ControlRow label="Контур">
<StatusBadge tone="warning">Временная симуляция</StatusBadge>
</ControlRow>
<RangeControl
label="Количество пинов"
value={configuration.count}
min={1}
max={100}
step={1}
formatValue={(value) => `${value}`}
onChange={(count) => updateDraft((current) => {
current.view.ghostPins.count = count;
return current;
})}
/>
<RangeControl
label="Радиус от центра view"
value={configuration.radiusMeters}
min={100}
max={100_000}
step={100}
formatValue={(value) => value >= 1_000
? `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)} км`
: `${value} м`}
onChange={(radiusMeters) => updateDraft((current) => {
current.view.ghostPins.radiusMeters = radiusMeters;
return current;
})}
/>
<RangeControl
label="Максимальная скорость"
value={configuration.maximumSpeedMetersPerSecond}
min={1}
max={100}
step={1}
formatValue={(value) => `${value} м/с`}
onChange={(maximumSpeedMetersPerSecond) => updateDraft((current) => {
current.view.ghostPins.maximumSpeedMetersPerSecond =
maximumSpeedMetersPerSecond;
current.view.ghostPins.positions =
current.view.ghostPins.positions.map((pin) => ({
...pin,
speedMetersPerSecond: Math.min(
pin.speedMetersPerSecond,
maximumSpeedMetersPerSecond,
),
}));
return current;
})}
/>
<Checker
label="Симуляция движения"
checked={configuration.simulationEnabled}
onChange={(simulationEnabled) => updateDraft((current) => {
current.view.ghostPins.simulationEnabled = simulationEnabled;
return current;
})}
/>
<ControlRow label="Позиции">
<StatusBadge tone={configuration.positions.length ? "success" : "neutral"}>
{configuration.positions.length
? `${configuration.positions.length} на карте`
: "Не созданы"}
</StatusBadge>
</ControlRow>
<Button variant="secondary" size="compact" onClick={onGenerate}>
Разместить в текущем view
</Button>
<Button
variant="ghost"
size="compact"
disabled={configuration.positions.length === 0}
onClick={onClear}
>
Очистить позиции на карте
</Button>
<Checker
label="Показывать Ghost Pin"
checked={draft.view.layerVisibility.targets}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.targets = checked;
return current;
})}
/>
<ControlRow label="Цвет пина">
<ColorField
label="Цвет Ghost Pin"
value={profile.color}
onChange={(color) => updateDraft((current) => {
current.view.ghostPins.presentation.color = color;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность пина"
value={Math.round(profile.opacity * 100)}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.ghostPins.presentation.opacity = value / 100;
return current;
})}
/>
<RangeControl
label="Высота таргета"
value={profile.stemHeightMeters}
min={100}
max={10_000}
step={50}
formatValue={(value) => `${value} м`}
onChange={(stemHeightMeters) => updateDraft((current) => {
current.view.ghostPins.presentation.stemHeightMeters = stemHeightMeters;
return current;
})}
/>
<RangeControl
label="Размер головки"
value={profile.headSizePx}
min={1}
max={32}
step={1}
formatValue={(value) => `${value} px`}
onChange={(headSizePx) => updateDraft((current) => {
current.view.ghostPins.presentation.headSizePx = headSizePx;
return current;
})}
/>
<RangeControl
label="Толщина стержня"
value={profile.stemWidthPx}
min={0.25}
max={12}
step={0.25}
formatValue={(value) => `${value} px`}
onChange={(stemWidthPx) => updateDraft((current) => {
current.view.ghostPins.presentation.stemWidthPx = stemWidthPx;
return current;
})}
/>
<ControlRow label="Цвет обводки">
<ColorField
label="Цвет обводки головки"
value={profile.outlineColor}
onChange={(outlineColor) => updateDraft((current) => {
current.view.ghostPins.presentation.outlineColor = outlineColor;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность обводки"
value={Math.round(profile.outlineOpacity * 100)}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.ghostPins.presentation.outlineOpacity = value / 100;
return current;
})}
/>
<RangeControl
label="Толщина обводки"
value={profile.outlineWidthPx}
min={0}
max={8}
step={0.5}
formatValue={(value) => `${value} px`}
onChange={(outlineWidthPx) => updateDraft((current) => {
current.view.ghostPins.presentation.outlineWidthPx = outlineWidthPx;
return current;
})}
/>
<InspectorSelectField<MapGhostPinLabelMode>
label="Подпись"
value={profile.labelMode}
options={[
{
value: "attributes",
label: "Имя",
description: "Случайное числовое имя Ghost Pin",
},
{
value: "subject_id",
label: "ID",
description: "Стабильный синтетический идентификатор",
},
{
value: "none",
label: "Нет",
description: "Не показывать плашку",
},
]}
onChange={(labelMode) => updateDraft((current) => {
current.view.ghostPins.presentation.labelMode = labelMode;
return current;
})}
/>
<RangeControl
label="Насыщенность шрифта"
value={profile.labelFontWeight}
min={400}
max={700}
step={100}
formatValue={(value) => `${value}`}
onChange={(labelFontWeight) => updateDraft((current) => {
current.view.ghostPins.presentation.labelFontWeight = labelFontWeight;
return current;
})}
/>
<RangeControl
label="Размер подписи"
value={profile.labelSizePx}
min={8}
max={32}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelSizePx) => updateDraft((current) => {
current.view.ghostPins.presentation.labelSizePx = labelSizePx;
return current;
})}
/>
<ControlRow label="Цвет подписи">
<ColorField
label="Цвет текста подписи"
value={profile.labelColor}
onChange={(labelColor) => updateDraft((current) => {
current.view.ghostPins.presentation.labelColor = labelColor;
return current;
})}
/>
</ControlRow>
<ControlRow label="Фон плашки">
<ColorField
label="Цвет фона подписи"
value={profile.labelBackgroundColor}
onChange={(labelBackgroundColor) => updateDraft((current) => {
current.view.ghostPins.presentation.labelBackgroundColor =
labelBackgroundColor;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность плашки"
value={Math.round(profile.labelBackgroundOpacity * 100)}
min={0}
max={100}
step={1}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.ghostPins.presentation.labelBackgroundOpacity =
value / 100;
return current;
})}
/>
<RangeControl
label="Отступ плашки X"
value={profile.labelPaddingX}
min={0}
max={32}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelPaddingX) => updateDraft((current) => {
current.view.ghostPins.presentation.labelPaddingX = labelPaddingX;
return current;
})}
/>
<RangeControl
label="Отступ плашки Y"
value={profile.labelPaddingY}
min={0}
max={32}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelPaddingY) => updateDraft((current) => {
current.view.ghostPins.presentation.labelPaddingY = labelPaddingY;
return current;
})}
/>
<RangeControl
label="Смещение подписи X"
value={profile.labelOffsetX}
min={-100}
max={100}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelOffsetX) => updateDraft((current) => {
current.view.ghostPins.presentation.labelOffsetX = labelOffsetX;
return current;
})}
/>
<RangeControl
label="Смещение подписи Y"
value={profile.labelOffsetY}
min={-100}
max={100}
step={1}
formatValue={(value) => `${value} px`}
onChange={(labelOffsetY) => updateDraft((current) => {
current.view.ghostPins.presentation.labelOffsetY = labelOffsetY;
return current;
})}
/>
<RangeControl
label="Максимальная длина подписи"
value={profile.labelMaxLength}
min={1}
max={64}
step={1}
formatValue={(value) => `${value}`}
onChange={(labelMaxLength) => updateDraft((current) => {
current.view.ghostPins.presentation.labelMaxLength = labelMaxLength;
return current;
})}
/>
<RangeControl
label="Скрывать подпись выше"
value={profile.labelHideCameraHeightMeters}
min={1_000}
max={500_000}
step={1_000}
formatValue={(value) => `${Math.round(value / 1_000)} км`}
onChange={(labelHideCameraHeightMeters) => updateDraft((current) => {
current.view.ghostPins.presentation.labelHideCameraHeightMeters =
labelHideCameraHeightMeters;
return current;
})}
/>
<RangeControl
label="Скрывать таргет выше"
value={profile.targetHideCameraHeightMeters}
min={1_000}
max={500_000}
step={1_000}
formatValue={(value) => `${Math.round(value / 1_000)} км`}
onChange={(targetHideCameraHeightMeters) => updateDraft((current) => {
current.view.ghostPins.presentation.targetHideCameraHeightMeters =
targetHideCameraHeightMeters;
return current;
})}
/>
</div>
);
}
@@ -0,0 +1,708 @@
import {
Checker,
ColorField,
ControlRow,
Inspector,
RangeControl,
StatusBadge,
} from "@nodedc/ui-react";
import type {
MapInspectorSection,
MapViewDocument,
} from "../../core/map/mapView";
import type { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth";
import { GhostPinSettingsPanel } from "./GhostPinSettingsPanel";
export const editableMapInspectorSections = new Set<MapInspectorSection>([
"base-terrain",
"atmosphere-light",
"buildings",
"ghost-pins",
"grid-lod",
"camera",
"tile-cache",
]);
export function MapSettingsInspector({
draft,
updateDraft,
gatewayHealth,
onGenerateGhostPins,
onClearGhostPins,
}: {
draft: MapViewDocument;
updateDraft: (
update: (current: MapViewDocument) => MapViewDocument,
) => void;
gatewayHealth: ReturnType<typeof useMapGatewayHealth>;
onGenerateGhostPins: () => void;
onClearGhostPins: () => void;
}) {
return (
<Inspector
singleOpen
sections={createInspectorSections(
draft,
updateDraft,
gatewayHealth,
onGenerateGhostPins,
onClearGhostPins,
)}
openSections={draft.view.inspectorOpenSections.filter(
(section) => editableMapInspectorSections.has(section),
)}
activeId={draft.view.inspectorOpenSections[0]}
onOpenSectionsChange={(sections) => {
updateDraft((current) => {
current.view.inspectorOpenSections = sections.filter(
(section): section is MapInspectorSection =>
editableMapInspectorSections.has(section as MapInspectorSection),
).slice(0, 1);
return current;
});
}}
/>
);
}
function createInspectorSections(
draft: MapViewDocument,
updateDraft: (
update: (current: MapViewDocument) => MapViewDocument,
) => void,
gatewayHealth: ReturnType<typeof useMapGatewayHealth>,
onGenerateGhostPins: () => void,
onClearGhostPins: () => void,
) {
const view = draft.view;
const settings = view.visualSettings;
return [
{
id: "base-terrain",
label: "Подложка и terrain",
description: "provider-neutral surface",
content: (
<div className="mission-map__inspector-controls">
<ControlRow label="Подложка">
<strong>Cesium World Imagery</strong>
</ControlRow>
<Checker
label="Спутниковая подложка"
checked={view.layerVisibility.imagery}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.imagery = checked;
return current;
})}
/>
<Checker
label="Terrain"
checked={view.layerVisibility.terrain}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.terrain = checked;
return current;
})}
/>
<RangeControl
label="Вертикальное преувеличение рельефа"
value={settings.terrainExaggeration * 100}
min={25}
max={300}
step={1}
formatValue={(value) => `${(value / 100).toFixed(2)}×`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.terrainExaggeration = value / 100;
return current;
})}
/>
<Checker
label="Монохромная поверхность"
checked={settings.monochromeEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.monochromeEnabled = checked;
return current;
})}
/>
<ControlRow label="Цвет монохрома">
<ColorField
label="Цвет монохромной поверхности"
value={settings.monochromeColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.monochromeColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Яркость"
value={settings.imageryBrightness}
min={0}
max={200}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryBrightness = value;
return current;
})}
/>
<RangeControl
label="Контраст"
value={settings.imageryContrast}
min={0}
max={200}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryContrast = value;
return current;
})}
/>
<RangeControl
label="Насыщенность"
value={settings.imagerySaturation}
min={0}
max={200}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imagerySaturation = value;
return current;
})}
/>
<RangeControl
label="Гамма"
value={settings.imageryGamma}
min={0}
max={300}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryGamma = value;
return current;
})}
/>
<RangeControl
label="Оттенок"
value={settings.imageryHue}
min={-180}
max={180}
formatValue={(value) => `${value}°`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryHue = value;
return current;
})}
/>
<RangeControl
label="Прозрачность imagery"
value={settings.imageryAlpha}
min={0}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.imageryAlpha = value;
return current;
})}
/>
<ControlRow label="Цвет планеты">
<ColorField
label="Цвет terrain без imagery"
value={settings.globeColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.globeColor = value;
return current;
})}
/>
</ControlRow>
<ControlRow label="Фон сцены">
<ColorField
label="Цвет фона сцены"
value={settings.backgroundColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.backgroundColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Высота карты"
value={view.mapHeight}
min={420}
max={1080}
step={1}
formatValue={(value) => `${value} px`}
onChange={(value) => updateDraft((current) => {
current.view.mapHeight = value;
return current;
})}
/>
</div>
),
},
{
id: "atmosphere-light",
label: "Атмосфера и освещение",
description: "scene / color correction",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Показывать атмосферу"
checked={settings.atmosphereEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.atmosphereEnabled = checked;
return current;
})}
/>
<RangeControl
label="Атмосфера: оттенок"
value={settings.atmosphereHue}
min={-100}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.atmosphereHue = value;
return current;
})}
/>
<RangeControl
label="Атмосфера: насыщенность"
value={settings.atmosphereSaturation}
min={-100}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.atmosphereSaturation = value;
return current;
})}
/>
<RangeControl
label="Атмосфера: яркость"
value={settings.atmosphereBrightness}
min={-100}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.atmosphereBrightness = value;
return current;
})}
/>
<Checker
label="Туман"
checked={settings.fogEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.fogEnabled = checked;
return current;
})}
/>
<RangeControl
label="Плотность тумана"
value={settings.fogDensity}
min={0}
max={100}
formatValue={(value) => `${(value / 10_000).toFixed(4)}`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.fogDensity = value;
return current;
})}
/>
<Checker
label="Солнечное освещение"
checked={settings.sunEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.sunEnabled = checked;
return current;
})}
/>
<RangeControl
label="Час солнца"
value={settings.sunHour}
min={0}
max={24}
formatValue={(value) => `${value}:00 UTC`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.sunHour = value;
return current;
})}
/>
<RangeControl
label="Интенсивность света"
value={settings.sunIntensity}
min={0}
max={200}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.sunIntensity = value;
return current;
})}
/>
<Checker
label="Тени"
checked={settings.shadowsEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.shadowsEnabled = checked;
return current;
})}
/>
</div>
),
},
{
id: "buildings",
label: "3D здания",
description: "3D Tiles / detail",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Показывать 3D здания"
checked={view.layerVisibility.buildings}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.buildings = checked;
return current;
})}
/>
<ControlRow label="Цвет">
<ColorField
label="Цвет зданий"
value={settings.buildingsColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.buildingsColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность"
value={Math.round(settings.buildingsOpacity * 100)}
min={0}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.buildingsOpacity = value / 100;
return current;
})}
/>
<RangeControl
label="Детализация"
value={settings.buildingsMaximumScreenSpaceError}
min={4}
max={32}
formatValue={(value) => `SSE ${value}`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.buildingsMaximumScreenSpaceError = value;
return current;
})}
/>
</div>
),
},
{
id: "ghost-pins",
label: "Гост-пин",
description: "временная симуляция",
content: (
<GhostPinSettingsPanel
draft={draft}
updateDraft={updateDraft}
onGenerate={onGenerateGhostPins}
onClear={onClearGhostPins}
/>
),
},
{
id: "grid-lod",
label: "Сетка и LOD",
description: "first adapter control",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="3D-сетка"
checked={view.layerVisibility.grid}
onChange={(checked) => updateDraft((current) => {
current.view.layerVisibility.grid = checked;
return current;
})}
/>
<Checker
label="LOD по высоте камеры"
checked={settings.gridLodEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.gridLodEnabled = checked;
return current;
})}
/>
<RangeControl
label="Высота над поверхностью"
value={settings.gridHeightMeters}
min={0}
max={1000}
formatValue={(value) => `${value} м`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridHeightMeters = value;
return current;
})}
/>
<RangeControl
label="LOD 1: до высоты"
value={settings.gridLod1MaxHeightKm}
min={1}
max={50}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod1MaxHeightKm = value;
return current;
})}
/>
<RangeControl
label="LOD 1: шаг"
value={settings.gridLod1StepKm}
min={1}
max={10}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod1StepKm = value;
return current;
})}
/>
<RangeControl
label="LOD 2: до высоты"
value={settings.gridLod2MaxHeightKm}
min={10}
max={200}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod2MaxHeightKm = value;
return current;
})}
/>
<RangeControl
label="LOD 2: шаг"
value={settings.gridLod2StepKm}
min={1}
max={25}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod2StepKm = value;
return current;
})}
/>
<RangeControl
label="LOD 3: шаг"
value={settings.gridLod3StepKm}
min={5}
max={100}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLod3StepKm = value;
return current;
})}
/>
<RangeControl
label="Радиус сетки"
value={settings.gridRadiusKm}
min={5}
max={150}
formatValue={(value) => `${value} км`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridRadiusKm = value;
return current;
})}
/>
<ControlRow label="Цвет линий">
<ColorField
label="Цвет линий сетки"
value={settings.gridColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Толщина линий"
value={settings.gridLineWidth}
min={1}
max={8}
formatValue={(value) => `${value} px`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridLineWidth = value;
return current;
})}
/>
<RangeControl
label="Прозрачность сетки"
value={settings.gridOpacity}
min={0}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridOpacity = value;
return current;
})}
/>
<Checker
label="Точки в пересечениях"
checked={settings.gridDotsEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.gridDotsEnabled = checked;
return current;
})}
/>
<RangeControl
label="Размер точки"
value={settings.gridDotsSize}
min={2}
max={28}
formatValue={(value) => `${value} px`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridDotsSize = value;
return current;
})}
/>
<ControlRow label="Цвет точек">
<ColorField
label="Цвет точек сетки"
value={settings.gridDotsColor}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridDotsColor = value;
return current;
})}
/>
</ControlRow>
<RangeControl
label="Прозрачность точек"
value={settings.gridDotsOpacity}
min={0}
max={100}
formatValue={(value) => `${value}%`}
onChange={(value) => updateDraft((current) => {
current.view.visualSettings.gridDotsOpacity = value;
return current;
})}
/>
</div>
),
},
{
id: "camera",
label: "Анимация камеры",
description: "geodesic spiral survey",
content: (
<div className="mission-map__inspector-controls">
<Checker
label="Разрешить анимацию камеры"
checked={settings.cameraAnimationEnabled}
onChange={(checked) => updateDraft((current) => {
current.view.visualSettings.cameraAnimationEnabled = checked;
return current;
})}
/>
<ControlRow label="Сохранённая позиция">
<StatusBadge tone={view.camera ? "success" : "neutral"}>
{view.camera ? "Есть" : "Не задана"}
</StatusBadge>
</ControlRow>
</div>
),
},
{
id: "tile-cache",
label: "TileCache",
description: "Platform Map Gateway",
content: (
<div
className="mission-map__inspector-controls"
data-health-code={gatewayHealth.code ?? undefined}
>
<Checker
label="Использовать общий TileCache"
checked={view.cacheIntent.enabled}
onChange={(checked) => updateDraft((current) => {
current.view.cacheIntent.enabled = checked;
return current;
})}
/>
<Checker
label="Не перезаписывать существующие тайлы"
checked={view.cacheIntent.noOverwrite}
disabled={!view.cacheIntent.enabled}
onChange={(checked) => updateDraft((current) => {
current.view.cacheIntent.noOverwrite = checked;
return current;
})}
/>
<CacheHealth health={gatewayHealth} />
</div>
),
},
];
}
function CacheHealth({
health,
}: {
health: ReturnType<typeof useMapGatewayHealth>;
}) {
if (health.state === "loading" || health.state === "idle") {
return (
<ControlRow label="Состояние">
<StatusBadge tone="neutral">Проверяем</StatusBadge>
</ControlRow>
);
}
if (!health.snapshot) {
return (
<ControlRow label="Состояние">
<StatusBadge tone="danger">Недоступен</StatusBadge>
</ControlRow>
);
}
const { cache } = health.snapshot;
return (
<dl className="mission-map__cache-health">
<div>
<dt>Хранилище</dt>
<dd>
<StatusBadge tone={cache.persistent ? "success" : "warning"}>
{cache.persistent ? "Постоянное" : "Временное"}
</StatusBadge>
</dd>
</div>
<div>
<dt>Объекты</dt>
<dd>{cache.entries.toLocaleString("ru-RU")}</dd>
</div>
<div>
<dt>Объём</dt>
<dd>
{formatBytes(cache.bytes)}
{cache.maxBytes === null ? "" : ` / ${formatBytes(cache.maxBytes)}`}
</dd>
</div>
<div>
<dt>Заполнение</dt>
<dd>
<StatusBadge
tone={
cache.atCapacity || health.state === "stale"
? "warning"
: "success"
}
>
{cache.atCapacity
? "Лимит"
: health.state === "stale"
? "Устарело"
: "Норма"}
</StatusBadge>
</dd>
</div>
</dl>
);
}
function formatBytes(value: number): string {
if (value < 1024) return `${value} Б`;
const units = ["КБ", "МБ", "ГБ", "ТБ"];
let amount = value / 1024;
let unit = units[0];
for (let index = 1; index < units.length && amount >= 1024; index += 1) {
amount /= 1024;
unit = units[index];
}
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`;
}
@@ -0,0 +1,560 @@
import {
Application,
Asset,
BLEND_NORMAL,
Color,
CULLFACE_NONE,
Entity,
FILLMODE_NONE,
Mat4,
RESOLUTION_AUTO,
StandardMaterial,
Vec2,
Vec3,
WasmModule,
type ContainerResource,
type RenderComponent,
type ScriptType,
} from "playcanvas";
import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs";
import type {
SimulationTransformAxis,
SimulationViewerQuality,
SimulationWorldManifest,
} from "../../core/simulation/projects";
export type SimulationViewMode = "visual" | "collision" | "combined";
export type SimulationQuality = SimulationViewerQuality;
export type SimulationLayer = "visual" | "collision";
export type SimulationCameraAxis = "horizontal" | "vertical";
export const PLAYCANVAS_IDENTITY_TRANSFORM = [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
];
export const PLAYCANVAS_X_180_TRANSFORM = [
1, 0, 0, 0,
0, -1, 0, 0,
0, 0, -1, 0,
0, 0, 0, 1,
];
export const PLAYCANVAS_Y_180_TRANSFORM = [
-1, 0, 0, 0,
0, 1, 0, 0,
0, 0, -1, 0,
0, 0, 0, 1,
];
export const PLAYCANVAS_Z_180_TRANSFORM = [
-1, 0, 0, 0,
0, -1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
];
export function playCanvasAxisTransform(axis: SimulationTransformAxis, inverted: boolean): number[] {
if (!inverted) return PLAYCANVAS_IDENTITY_TRANSFORM;
if (axis === "x") return PLAYCANVAS_X_180_TRANSFORM;
if (axis === "y") return PLAYCANVAS_Y_180_TRANSFORM;
return PLAYCANVAS_Z_180_TRANSFORM;
}
export interface SimulationRuntime {
mount(canvas: HTMLCanvasElement): Promise<void>;
loadWorld(manifest: SimulationWorldManifest): Promise<void>;
setViewMode(mode: SimulationViewMode): Promise<void>;
setQuality(quality: SimulationQuality): void;
setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void;
setCameraInversion(axis: SimulationCameraAxis, inverted: boolean): void;
home(): void;
focusBounds(): void;
dispose(): void;
}
type DesktopCameraInput = {
read(): { mouse: number[] } & Record<string, number[]>;
_pointerId?: number;
_button?: number[];
_keyNow?: number[];
_keyPrev?: number[];
};
type CameraController = ScriptType & Pick<CameraControls, "reset" | "focus"> & {
_desktopInput?: DesktopCameraInput;
_state?: {
axis: Vec3;
shift: number;
ctrl: number;
mouse: number[];
touches: number;
};
};
const HOME_POSITION = new Vec3(0, 1, 0);
const HOME_FOCUS = new Vec3(1, 1, 0);
const DRACO_DECODER_ROOT = "/vendor/playcanvas/draco/1.5.7";
const QUALITY_PROFILES: Record<SimulationQuality, {
lodBaseDistance: number;
lodMultiplier: number;
lodRangeMin: number;
lodRangeMax: number;
pixelRatio: number;
splatBudget: number;
targetFps: number;
}> = {
low: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 3, lodRangeMax: 5, pixelRatio: 0.75, splatBudget: 350_000, targetFps: 30 },
medium: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 2, lodRangeMax: 5, pixelRatio: 1, splatBudget: 700_000, targetFps: 45 },
high: { lodBaseDistance: 5, lodMultiplier: 3, lodRangeMin: 1, lodRangeMax: 5, pixelRatio: 1.25, splatBudget: 1_200_000, targetFps: 60 },
ultra: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 1.5, splatBudget: 2_500_000, targetFps: 60 },
maximum: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2, splatBudget: 4_000_000, targetFps: 60 },
};
export class PlayCanvasRuntime implements SimulationRuntime {
private app: Application | null = null;
private canvas: HTMLCanvasElement | null = null;
private camera: Entity | null = null;
private cameraController: CameraController | null = null;
private visualEntity: Entity | null = null;
private visualAsset: Asset | null = null;
private collisionEntity: Entity | null = null;
private collisionAsset: Asset | null = null;
private collisionMaterial: StandardMaterial | null = null;
private collisionSource: { meshUrl: string; projectId: string; worldTransform: number[] } | null = null;
private collisionLoadPromise: Promise<void> | null = null;
private resizeObserver: ResizeObserver | null = null;
private viewMode: SimulationViewMode = "visual";
private quality: SimulationQuality = "high";
private cameraInversion = { horizontal: true, vertical: false };
private renderIntervalMs = 1000 / 60;
private renderAccumulatorMs = 0;
private disposed = false;
private scheduleFrame = (deltaMs: number): void => {
if (!this.app || this.disposed) return;
this.renderAccumulatorMs += deltaMs;
if (this.renderAccumulatorMs < this.renderIntervalMs) return;
this.renderAccumulatorMs %= this.renderIntervalMs;
this.app.renderNextFrame = true;
};
async mount(canvas: HTMLCanvasElement): Promise<void> {
if (this.app) throw new Error("PlayCanvas runtime уже смонтирован.");
this.disposed = false;
this.renderAccumulatorMs = 0;
this.canvas = canvas;
configureDracoDecoder();
canvas.tabIndex = 0;
canvas.addEventListener("contextmenu", preventContextMenu);
canvas.addEventListener("pointerdown", focusCanvas, true);
const app = new Application(canvas, {
graphicsDeviceOptions: {
antialias: true,
alpha: false,
preserveDrawingBuffer: false,
powerPreference: "high-performance",
},
});
this.app = app;
app.autoRender = false;
app.on("frameupdate", this.scheduleFrame);
app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
app.setCanvasResolution(RESOLUTION_AUTO);
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
const camera = new Entity("SimulationCamera");
camera.addComponent("camera", {
clearColor: new Color(0.025, 0.03, 0.04),
nearClip: 0.02,
farClip: 20_000,
fov: 58,
});
camera.setPosition(HOME_POSITION);
camera.lookAt(HOME_FOCUS);
camera.addComponent("script");
const controller = camera.script?.create(CameraControls, {
properties: {
enableOrbit: true,
enableFly: true,
focusPoint: new Vec3(0, 0, 0),
zoomRange: new Vec2(0.05, 20_000),
},
}) as CameraController | null;
if (controller) configureCameraDrag(controller, () => this.cameraInversion);
app.root.addChild(camera);
this.camera = camera;
this.cameraController = controller;
const light = new Entity("SimulationSun");
light.addComponent("light", {
type: "directional",
color: new Color(1, 0.97, 0.9),
intensity: 1.1,
castShadows: false,
});
light.setEulerAngles(42, 28, 0);
app.root.addChild(light);
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas.parentElement ?? canvas);
this.resize();
app.start();
await Promise.resolve();
}
async loadWorld(manifest: SimulationWorldManifest): Promise<void> {
const app = this.requiredApp();
this.unloadWorld();
const sourceUrl = manifest.visual.streamedSogUrl ?? manifest.visual.previewSogUrl;
if (!sourceUrl) throw new Error("World manifest не содержит визуальный SOG.");
const asset = new Asset(`SimulationWorld:${manifest.projectId}`, "gsplat", { url: sourceUrl });
app.assets.add(asset);
this.visualAsset = asset;
await new Promise<void>((resolve, reject) => {
const ready = (loaded: Asset) => {
if (this.disposed || !this.app) {
loaded.unload();
resolve();
return;
}
const visual = new Entity("GaussianWorld");
visual.enabled = false;
applyWorldTransform(visual, manifest.transforms.worldFromVisual);
app.root.addChild(visual);
visual.addComponent("gsplat", {
asset: loaded,
unified: true,
});
visual.enabled = true;
this.visualEntity = visual;
this.applyQuality();
this.applyViewMode();
resolve();
};
const failed = (error: unknown) => {
reject(new Error(error instanceof Error ? error.message : "PlayCanvas не загрузил SOG."));
};
asset.ready(ready);
asset.once("error", failed);
app.assets.load(asset);
});
if (manifest.collision.available && manifest.collision.meshUrl) {
this.collisionSource = {
meshUrl: manifest.collision.meshUrl,
projectId: manifest.projectId,
worldTransform: manifest.transforms.worldFromCollision,
};
}
this.applyViewMode();
this.home();
}
async setViewMode(mode: SimulationViewMode): Promise<void> {
this.resetCameraInteraction();
this.viewMode = mode;
this.applyViewMode();
try {
if (mode !== "visual" && !this.collisionEntity) {
await this.ensureCollision();
this.applyViewMode();
}
} finally {
this.canvas?.focus({ preventScroll: true });
}
}
setQuality(quality: SimulationQuality): void {
this.quality = quality;
this.applyQuality();
}
setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void {
if (transform.length !== 16) {
throw new Error("World transform должен содержать матрицу 4×4.");
}
if (layer === "visual") {
if (this.visualEntity) applyWorldTransform(this.visualEntity, transform);
return;
}
if (this.collisionSource) this.collisionSource.worldTransform = [...transform];
if (this.collisionEntity) applyWorldTransform(this.collisionEntity, transform);
}
setCameraInversion(axis: SimulationCameraAxis, inverted: boolean): void {
this.cameraInversion[axis] = inverted;
}
home(): void {
this.resetCameraInteraction();
if (this.cameraController) {
this.cameraController.reset(HOME_FOCUS, HOME_POSITION);
} else if (this.camera) {
this.camera.setPosition(HOME_POSITION);
this.camera.lookAt(HOME_FOCUS);
}
this.canvas?.focus({ preventScroll: true });
}
focusBounds(): void {
this.resetCameraInteraction();
const bounds = this.visualEntity?.gsplat?.customAabb;
const focus = bounds?.center.clone() ?? new Vec3(0, 0, 0);
const radius = bounds?.halfExtents.length() ?? 3;
const distance = Math.max(1, radius * 2.4);
const position = new Vec3(1, 0.55, 1).normalize().mulScalar(distance).add(focus);
if (this.cameraController) {
this.cameraController.reset(focus, position);
} else if (this.camera) {
this.camera.setPosition(position);
this.camera.lookAt(focus);
}
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.resizeObserver?.disconnect();
this.resizeObserver = null;
const webgl = this.canvas?.getContext("webgl2") ?? this.canvas?.getContext("webgl");
this.unloadWorld();
if (this.app) {
this.app.autoRender = false;
this.app.renderNextFrame = false;
this.app.off("frameupdate", this.scheduleFrame);
this.app.destroy();
}
webgl?.getExtension("WEBGL_lose_context")?.loseContext();
if (this.canvas) {
this.canvas.width = 1;
this.canvas.height = 1;
}
if (this.canvas) {
this.canvas.removeEventListener("contextmenu", preventContextMenu);
this.canvas.removeEventListener("pointerdown", focusCanvas, true);
}
this.app = null;
this.canvas = null;
this.camera = null;
this.cameraController = null;
}
private requiredApp(): Application {
if (!this.app) throw new Error("PlayCanvas runtime не смонтирован.");
return this.app;
}
private resize(): void {
if (!this.app || !this.canvas) return;
const bounds = this.canvas.parentElement?.getBoundingClientRect();
const width = Math.max(1, Math.round(bounds?.width ?? this.canvas.clientWidth));
const height = Math.max(1, Math.round(bounds?.height ?? this.canvas.clientHeight));
this.app.resizeCanvas(width, height);
}
private applyViewMode(): void {
if (this.visualEntity) {
this.visualEntity.enabled = this.viewMode !== "collision" || !this.collisionEntity;
}
if (this.collisionEntity) {
this.collisionEntity.enabled = this.viewMode !== "visual";
}
if (this.collisionMaterial) {
const combined = this.viewMode === "combined";
this.collisionMaterial.opacity = combined ? 0.34 : 0.82;
this.collisionMaterial.depthWrite = !combined;
this.collisionMaterial.update();
}
}
private resetCameraInteraction(): void {
const controller = this.cameraController;
const canvas = this.canvas;
if (!controller || !canvas) return;
const input = controller._desktopInput;
const pointerId = input?._pointerId ?? -1;
if (pointerId >= 0 && canvas.hasPointerCapture(pointerId)) {
try {
canvas.releasePointerCapture(pointerId);
} catch {
// The browser may already have released capture after a trackpad gesture.
}
}
if (input) {
input._pointerId = -1;
input._button?.fill(0);
input._keyNow?.fill(0);
input._keyPrev?.fill(0);
input.read();
}
controller.fire("state");
const state = controller._state;
if (state) {
state.axis.set(0, 0, 0);
state.shift = 0;
state.ctrl = 0;
state.mouse.fill(0);
state.touches = 0;
}
}
private applyQuality(): void {
const gsplat = this.visualEntity?.gsplat;
const profile = QUALITY_PROFILES[this.quality];
if (gsplat) {
gsplat.lodBaseDistance = profile.lodBaseDistance;
gsplat.lodMultiplier = profile.lodMultiplier;
gsplat.lodRangeMin = profile.lodRangeMin;
gsplat.lodRangeMax = profile.lodRangeMax;
}
if (this.app) {
this.app.scene.gsplat.splatBudget = profile.splatBudget;
this.renderIntervalMs = 1000 / profile.targetFps;
const devicePixelRatio = window.devicePixelRatio || 1;
this.app.graphicsDevice.maxPixelRatio = Math.min(devicePixelRatio, profile.pixelRatio);
this.resize();
}
}
private async loadCollision(meshUrl: string, projectId: string, worldTransform: number[]): Promise<void> {
const app = this.requiredApp();
const asset = new Asset(
`SimulationCollision:${projectId}`,
"container",
{ url: meshUrl, filename: "scene.collision.glb" },
);
app.assets.add(asset);
this.collisionAsset = asset;
await new Promise<void>((resolve, reject) => {
asset.ready((loaded) => {
if (this.disposed || !this.app) {
loaded.unload();
resolve();
return;
}
const resource = loaded.resource as ContainerResource | null;
if (!resource) {
reject(new Error("PlayCanvas не открыл collision GLB."));
return;
}
const entity = resource.instantiateRenderEntity({
castShadows: false,
receiveShadows: false,
});
entity.name = "CollisionWorld";
entity.enabled = false;
applyWorldTransform(entity, worldTransform);
app.root.addChild(entity);
const material = new StandardMaterial();
material.name = "SimulationCollisionMaterial";
material.diffuse = new Color(0.2, 0.95, 0.42);
material.emissive = new Color(0.08, 0.36, 0.14);
material.opacity = 0.34;
material.blendType = BLEND_NORMAL;
material.depthWrite = false;
material.cull = CULLFACE_NONE;
material.update();
for (const render of entity.findComponents("render") as RenderComponent[]) {
for (const meshInstance of render.meshInstances) {
meshInstance.material = material;
}
}
this.collisionEntity = entity;
this.collisionMaterial = material;
resolve();
});
asset.once("error", (error: unknown) => {
reject(new Error(error instanceof Error ? error.message : "PlayCanvas не загрузил collision GLB."));
});
app.assets.load(asset);
});
}
private async ensureCollision(): Promise<void> {
if (this.collisionEntity) return;
if (!this.collisionSource) {
throw new Error("Collision GLB отсутствует в world manifest.");
}
if (!this.collisionLoadPromise) {
this.collisionLoadPromise = this.loadCollision(
this.collisionSource.meshUrl,
this.collisionSource.projectId,
this.collisionSource.worldTransform,
).finally(() => {
this.collisionLoadPromise = null;
});
}
await this.collisionLoadPromise;
}
private unloadWorld(): void {
if (this.visualEntity) {
this.visualEntity.destroy();
this.visualEntity = null;
}
if (this.visualAsset && this.app) {
this.app.assets.remove(this.visualAsset);
this.visualAsset.unload();
this.visualAsset = null;
}
if (this.collisionEntity) {
this.collisionEntity.destroy();
this.collisionEntity = null;
}
if (this.collisionAsset && this.app) {
this.app.assets.remove(this.collisionAsset);
this.collisionAsset.unload();
this.collisionAsset = null;
}
this.collisionMaterial?.destroy();
this.collisionMaterial = null;
this.collisionSource = null;
this.collisionLoadPromise = null;
}
}
function preventContextMenu(event: Event): void {
event.preventDefault();
}
function configureDracoDecoder(): void {
if (WasmModule.getConfig("DracoDecoderModule")) return;
WasmModule.setConfig("DracoDecoderModule", {
glueUrl: `${DRACO_DECODER_ROOT}/draco_wasm_wrapper_gltf.js`,
wasmUrl: `${DRACO_DECODER_ROOT}/draco_decoder_gltf.wasm`,
numWorkers: 2,
});
}
function focusCanvas(event: Event): void {
(event.currentTarget as HTMLCanvasElement | null)?.focus({ preventScroll: true });
}
function configureCameraDrag(
controller: CameraController,
inversion: () => { horizontal: boolean; vertical: boolean },
): void {
// CameraControls 2.21.4 deliberately exposes one pinned input source here.
// Keep its pointer-capture and fly/orbit behavior; only the user-selected axes change sign.
const input = controller._desktopInput;
if (!input) return;
const read = input.read.bind(input);
input.read = () => {
const frame = read();
const selected = inversion();
if (selected.horizontal && frame.mouse.length > 0) frame.mouse[0] *= -1;
if (selected.vertical && frame.mouse.length > 1) frame.mouse[1] *= -1;
return frame;
};
}
function applyWorldTransform(entity: Entity, values: number[]): void {
const transform = new Mat4().set(values);
entity.setLocalPosition(transform.getTranslation());
entity.setLocalEulerAngles(transform.getEulerAngles());
entity.setLocalScale(transform.getScale());
}
@@ -0,0 +1,324 @@
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import {
ActivityIndicator,
Button,
FieldFrame,
Icon,
Select,
TextField,
Window,
WindowFooterActions,
} from "@nodedc/ui-react";
import {
createAndUploadSimulationProject,
updateSimulationProject,
type SimulationProject,
type SimulationSceneType,
type SimulationUploadCandidate,
type SimulationUploadProgress,
} from "../../core/simulation/projects";
export function SimulationProjectWindow({
open,
project,
onClose,
onSaved,
}: {
open: boolean;
project: SimulationProject | null;
onClose: () => void;
onSaved: (project: SimulationProject) => void;
}) {
const archiveInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
const [name, setName] = useState("");
const [sceneType, setSceneType] = useState<SimulationSceneType>("outdoor");
const [candidates, setCandidates] = useState<SimulationUploadCandidate[]>([]);
const [dragging, setDragging] = useState(false);
const [pending, setPending] = useState(false);
const [progress, setProgress] = useState<SimulationUploadProgress | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setName(project?.name ?? "");
setSceneType(project?.sceneType ?? "outdoor");
setCandidates([]);
setProgress(null);
setPending(false);
setError(null);
}, [open, project]);
const sourceSummary = useMemo(() => summarizeCandidates(candidates), [candidates]);
const canSubmit = name.trim().length > 0 && (project !== null || candidates.length > 0) && !pending;
const acceptCandidates = (next: SimulationUploadCandidate[]) => {
try {
validateCandidates(next);
setCandidates(next);
setError(null);
if (!name.trim() && next.length) {
const named = next.find((candidate) => /\.lcc2?$/i.test(candidate.logicalPath)) ?? next[0];
const first = named?.logicalPath.split("/").filter(Boolean).at(-1) ?? "Новая сцена";
setName(first.replace(/\.(lcc2?|zip|rar|7z)$/i, ""));
}
} catch (caught) {
setCandidates([]);
setError(caught instanceof Error ? caught.message : "Источник не принят.");
}
};
const submit = async () => {
if (!canSubmit) return;
setPending(true);
setError(null);
try {
const saved = project
? await updateSimulationProject(project.projectId, name, sceneType)
: await createAndUploadSimulationProject(
name,
sceneType,
candidates,
setProgress,
);
onSaved(saved);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось сохранить проект.");
setPending(false);
}
};
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setDragging(false);
try {
acceptCandidates(await candidatesFromDrop(event.dataTransfer));
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось прочитать источник.");
}
};
return (
<Window
open={open}
title={project ? "Редактировать проект" : "Новый проект симуляции"}
subtitle={project ? "Метаданные сцены" : "LCC/LCC2 folder или ZIP/RAR/7z"}
size="md"
closeOnBackdrop={!pending}
closeOnEscape={!pending}
onClose={onClose}
footer={(
<>
<span className="simulation-project-window__footer-state">
{pending && project ? <><ActivityIndicator /><span>Сохраняем</span></> : null}
</span>
<WindowFooterActions>
<Button disabled={pending} onClick={onClose}>Отмена</Button>
<Button variant="primary" disabled={!canSubmit} onClick={() => void submit()}>
{project ? "Сохранить" : "Создать и запустить"}
</Button>
</WindowFooterActions>
</>
)}
>
<div className="simulation-project-window">
<TextField
label="Название"
value={name}
maxLength={120}
disabled={pending}
autoComplete="off"
onChange={(event) => setName(event.currentTarget.value)}
/>
<FieldFrame label="Тип сцены" description="Сохраняется как профиль будущей физики и навигации.">
<Select
label="Тип сцены"
value={sceneType}
disabled={pending}
onChange={(value) => setSceneType(value)}
options={[
{ value: "outdoor", label: "Улица" },
{ value: "interior", label: "Интерьер" },
{ value: "object", label: "Отдельный объект" },
]}
/>
</FieldFrame>
{!project ? (
<FieldFrame
label="Исходник"
description="Имя родительской папки не фиксировано. Внутри должна быть ровно одна сцена LCC или LCC2."
>
<div
className="simulation-source-drop"
data-dragging={dragging ? "true" : undefined}
onDragEnter={(event) => { event.preventDefault(); setDragging(true); }}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false);
}}
onDrop={(event) => void handleDrop(event)}
>
<Icon name={candidates.length ? "check" : "upload"} size={20} />
<strong>{sourceSummary.title}</strong>
<span>{sourceSummary.detail}</span>
<div>
<Button
size="compact"
icon={<Icon name="file" size={16} />}
disabled={pending}
onClick={() => archiveInput.current?.click()}
>
Выбрать архив
</Button>
<Button
size="compact"
icon={<Icon name="folder" size={16} />}
disabled={pending}
onClick={() => folderInput.current?.click()}
>
Выбрать папку
</Button>
</div>
<input
ref={archiveInput}
type="file"
accept=".zip,.rar,.7z"
hidden
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) acceptCandidates([{ file, logicalPath: file.name }]);
event.currentTarget.value = "";
}}
/>
<input
ref={folderInput}
type="file"
hidden
multiple
{...({ webkitdirectory: "" } as Record<string, string>)}
onChange={(event) => {
const next = Array.from(event.currentTarget.files ?? []).map((file) => ({
file,
logicalPath: file.webkitRelativePath || file.name,
}));
if (next.length) acceptCandidates(next);
event.currentTarget.value = "";
}}
/>
</div>
</FieldFrame>
) : null}
{progress ? (
<div className="simulation-upload-progress" role="status">
<span><i style={{ width: `${Math.min(100, progress.totalBytes ? progress.uploadedBytes / progress.totalBytes * 100 : 0)}%` }} /></span>
<div>
<strong>{formatBytes(progress.uploadedBytes)} / {formatBytes(progress.totalBytes)}</strong>
<small>{progress.currentPath}</small>
</div>
</div>
) : null}
{error ? <p className="simulation-project-window__error" role="alert">{error}</p> : null}
</div>
</Window>
);
}
interface LegacyFileEntry {
isFile: boolean;
isDirectory: boolean;
name: string;
fullPath: string;
file?: (callback: (file: File) => void, error?: (error: DOMException) => void) => void;
createReader?: () => {
readEntries: (
callback: (entries: LegacyFileEntry[]) => void,
error?: (error: DOMException) => void,
) => void;
};
}
async function candidatesFromDrop(transfer: DataTransfer): Promise<SimulationUploadCandidate[]> {
const candidates: SimulationUploadCandidate[] = [];
for (const item of Array.from(transfer.items)) {
const entry = (item as DataTransferItem & {
webkitGetAsEntry?: () => LegacyFileEntry | null;
}).webkitGetAsEntry?.();
if (entry) {
await collectEntry(entry, candidates);
continue;
}
const file = item.getAsFile();
if (file) candidates.push({ file, logicalPath: file.name });
}
if (!candidates.length) {
for (const file of Array.from(transfer.files)) {
candidates.push({ file, logicalPath: file.name });
}
}
return candidates;
}
async function collectEntry(entry: LegacyFileEntry, target: SimulationUploadCandidate[]): Promise<void> {
if (entry.isFile && entry.file) {
const file = await new Promise<File>((resolve, reject) => entry.file?.(resolve, reject));
target.push({ file, logicalPath: entry.fullPath.replace(/^\//, "") || file.name });
return;
}
if (!entry.isDirectory || !entry.createReader) return;
const reader = entry.createReader();
while (true) {
const batch = await new Promise<LegacyFileEntry[]>((resolve, reject) => reader.readEntries(resolve, reject));
if (!batch.length) break;
for (const child of batch) await collectEntry(child, target);
}
}
function validateCandidates(candidates: SimulationUploadCandidate[]): void {
if (!candidates.length) throw new Error("Выберите архив или папку с результатом LCC/LCC2.");
const archives = candidates.filter((candidate) => /\.(zip|rar|7z)$/i.test(candidate.logicalPath));
if (archives.length) {
if (candidates.length !== 1) throw new Error("Архив нужно загружать одним файлом.");
return;
}
const descriptors = candidates.filter((candidate) => /\.lcc2?$/i.test(candidate.logicalPath));
if (descriptors.length !== 1) {
throw new Error("В папке должна быть ровно одна сцена .lcc или .lcc2.");
}
const paths = new Set<string>();
for (const candidate of candidates) {
const parts = candidate.logicalPath.split("/");
if (
!candidate.logicalPath ||
candidate.logicalPath.startsWith("/") ||
candidate.logicalPath.includes("\\") ||
candidate.logicalPath.includes("\0") ||
parts.some((part) => part === "" || part === "." || part === "..")
) {
throw new Error("Папка содержит небезопасный путь.");
}
if (candidate.file.size <= 0) throw new Error(`Пустой файл: ${candidate.logicalPath}`);
if (paths.has(candidate.logicalPath)) throw new Error(`Повторяющийся путь: ${candidate.logicalPath}`);
paths.add(candidate.logicalPath);
}
}
function summarizeCandidates(candidates: SimulationUploadCandidate[]): { title: string; detail: string } {
if (!candidates.length) return {
title: "Перетащите архив или папку сюда",
detail: "ZIP, RAR, 7z либо parent folder с LCC/LCC2",
};
const total = candidates.reduce((sum, candidate) => sum + candidate.file.size, 0);
const archive = candidates.length === 1 && /\.(zip|rar|7z)$/i.test(candidates[0]?.logicalPath ?? "");
return {
title: archive ? candidates[0]?.logicalPath ?? "Архив" : "Папка принята",
detail: `${candidates.length.toLocaleString("ru-RU")} файл(ов) · ${formatBytes(total)}`,
};
}
function formatBytes(value: number): string {
if (value < 1024) return `${value} Б`;
if (value < 1024 ** 2) return `${(value / 1024).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} КБ`;
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} МБ`;
return `${(value / 1024 ** 3).toLocaleString("ru-RU", { maximumFractionDigits: 2 })} ГБ`;
}
@@ -0,0 +1,400 @@
import { useEffect, useId, useRef, useState } from "react";
import {
ActivityIndicator,
Button,
Icon,
IconButton,
SegmentedControl,
Select,
StatusBadge,
Switch,
} from "@nodedc/ui-react";
import {
saveSimulationViewerSettings,
type SimulationProject,
type SimulationTransformAxis,
type SimulationViewerSettings,
} from "../../core/simulation/projects";
import {
createLatestAsyncCommitter,
type LatestAsyncCommitter,
} from "../../core/runtime/latestAsyncCommitter";
import {
PlayCanvasRuntime,
playCanvasAxisTransform,
type SimulationQuality,
type SimulationViewMode,
} from "./PlayCanvasRuntime";
const AXIS_OPTIONS: Array<{ value: SimulationTransformAxis; label: string }> = [
{ value: "x", label: "X" },
{ value: "y", label: "Y" },
{ value: "z", label: "Z" },
];
export function SimulationViewport({
project,
onProjectChange,
}: {
project: SimulationProject;
onProjectChange?: (project: SimulationProject) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
const viewerSettingsRef = useRef(project.viewerSettings);
const settingsCommitterRef = useRef<LatestAsyncCommitter<SimulationViewerSettings> | null>(null);
const settingsId = useId();
const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting");
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<SimulationViewMode>("visual");
const [quality, setQuality] = useState<SimulationQuality>(project.viewerSettings.quality);
const [collisionState, setCollisionState] = useState<"idle" | "loading" | "ready" | "failed">("idle");
const [settingsOpen, setSettingsOpen] = useState(false);
const [visualInverted, setVisualInverted] = useState(project.viewerSettings.visual.inverted);
const [visualAxis, setVisualAxis] = useState(project.viewerSettings.visual.axis);
const [collisionInverted, setCollisionInverted] = useState(project.viewerSettings.collision.inverted);
const [collisionAxis, setCollisionAxis] = useState(project.viewerSettings.collision.axis);
const [horizontalInverted, setHorizontalInverted] = useState(
project.viewerSettings.camera.invertHorizontal,
);
const [verticalInverted, setVerticalInverted] = useState(
project.viewerSettings.camera.invertVertical,
);
const [settingsError, setSettingsError] = useState<string | null>(null);
const worldManifestRevision = JSON.stringify(project.worldManifest);
useEffect(() => {
const settings = project.viewerSettings;
viewerSettingsRef.current = settings;
setQuality(settings.quality);
setVisualInverted(settings.visual.inverted);
setVisualAxis(settings.visual.axis);
setCollisionInverted(settings.collision.inverted);
setCollisionAxis(settings.collision.axis);
setHorizontalInverted(settings.camera.invertHorizontal);
setVerticalInverted(settings.camera.invertVertical);
setSettingsError(null);
setSettingsOpen(false);
}, [project.projectId]);
useEffect(() => {
let active = true;
const committer = createLatestAsyncCommitter<SimulationViewerSettings>({
async commit(settings) {
try {
const saved = await saveSimulationViewerSettings(project.projectId, settings);
if (active) onProjectChange?.(saved);
return true;
} catch (caught) {
if (active) {
setSettingsError(
caught instanceof Error ? caught.message : "Не удалось сохранить настройки сцены.",
);
}
return false;
}
},
onSettled({ applied, superseded }) {
if (active && applied && !superseded) setSettingsError(null);
},
});
settingsCommitterRef.current = committer;
return () => {
active = false;
committer.dispose();
if (settingsCommitterRef.current === committer) settingsCommitterRef.current = null;
};
}, [onProjectChange, project.projectId]);
useEffect(() => {
const canvas = canvasRef.current;
const manifest = project.worldManifest;
if (!canvas || !manifest) return;
const runtime = new PlayCanvasRuntime();
runtimeRef.current = runtime;
let cancelled = false;
setState("mounting");
setError(null);
void runtime.mount(canvas).then(async () => {
if (cancelled) return;
const settings = viewerSettingsRef.current;
runtime.setQuality(settings.quality);
runtime.setCameraInversion("horizontal", settings.camera.invertHorizontal);
runtime.setCameraInversion("vertical", settings.camera.invertVertical);
setState("loading");
await runtime.loadWorld(manifest);
runtime.setLayerWorldTransform(
"visual",
playCanvasAxisTransform(settings.visual.axis, settings.visual.inverted),
);
runtime.setLayerWorldTransform(
"collision",
playCanvasAxisTransform(settings.collision.axis, settings.collision.inverted),
);
if (!cancelled) setState("ready");
}).catch((caught: unknown) => {
if (cancelled) return;
setState("failed");
setError(caught instanceof Error ? caught.message : "Не удалось открыть сцену.");
});
return () => {
cancelled = true;
runtime.dispose();
runtimeRef.current = null;
};
// Saving viewer preferences returns a fresh project object. Restart the GPU
// runtime only when the manifest content changes, not when its object identity
// changes after an otherwise unrelated settings PUT.
}, [project.projectId, worldManifestRevision]);
const collisionAvailable = project.worldManifest?.collision.available ?? false;
const commitViewerSettings = (settings: SimulationViewerSettings) => {
viewerSettingsRef.current = settings;
settingsCommitterRef.current?.enqueue(settings);
};
return (
<section className="simulation-viewport" aria-label={`Сцена ${project.name}`}>
<header className="simulation-viewport__toolbar">
<div>
<StatusBadge tone={state === "failed" || collisionState === "failed" ? "warning" : state === "ready" && collisionState !== "loading" ? "success" : "accent"}>
{collisionState === "loading"
? "Загрузка collision"
: collisionState === "failed"
? "Ошибка collision"
: state === "ready"
? "Runtime готов"
: state === "failed"
? "Ошибка runtime"
: "Загрузка сцены"}
</StatusBadge>
<span>PlayCanvas Engine 2.21.4</span>
</div>
<div className="simulation-viewport__controls">
<Select
label="Качество Gaussian-сцены"
value={quality}
onChange={(next) => {
setQuality(next);
runtimeRef.current?.setQuality(next);
commitViewerSettings({ ...viewerSettingsRef.current, quality: next });
}}
options={[
{ value: "maximum", label: "Максимум", description: "До 4 млн сплатов · Retina до 2× · 60 FPS" },
{ value: "ultra", label: "Ультра", description: "До 2,5 млн · Retina до 1,5× · 60 FPS" },
{ value: "high", label: "Высокое", description: "До 1,2 млн · Retina до 1,25× · 60 FPS" },
{ value: "medium", label: "Среднее", description: "До 700 тыс. · 1× · 45 FPS" },
{ value: "low", label: "Низкое", description: "До 350 тыс. · 0,75× · 30 FPS" },
]}
minMenuWidth={230}
menuWidth={230}
/>
<SegmentedControl
label="Слой сцены"
value={viewMode}
onChange={(next) => {
setViewMode(next);
const runtime = runtimeRef.current;
if (!runtime) return;
if (next !== "visual" && collisionAvailable && collisionState !== "ready") {
setCollisionState("loading");
}
void runtime.setViewMode(next).then(() => {
if (next !== "visual") setCollisionState("ready");
}).catch((caught: unknown) => {
setCollisionState("failed");
setError(caught instanceof Error ? caught.message : "Не удалось открыть collision GLB.");
});
}}
items={[
{ value: "visual", label: "Визуал" },
{ value: "collision", label: "Коллизии", disabled: !collisionAvailable },
{ value: "combined", label: "Вместе", disabled: !collisionAvailable },
]}
/>
<Button size="compact" variant="secondary" onClick={() => runtimeRef.current?.home()}>
Домой
</Button>
<div className="simulation-viewport__settings-anchor">
<IconButton
label="Настройки сцены"
aria-controls={settingsId}
aria-expanded={settingsOpen}
aria-pressed={settingsOpen}
onClick={() => setSettingsOpen((open) => !open)}
>
<Icon name="settings" size={18} />
</IconButton>
{settingsOpen ? (
<div
id={settingsId}
className="simulation-viewport__settings"
role="dialog"
aria-label="Настройки сцены"
>
<div className="simulation-viewport__settings-head">
<div>
<strong>Настройки сцены</strong>
<span>Gaussian-мир и камера</span>
</div>
<IconButton label="Закрыть настройки" onClick={() => setSettingsOpen(false)}>
<Icon name="close" size={16} />
</IconButton>
</div>
<section className="simulation-viewport__settings-group">
<div className="simulation-viewport__settings-group-head">
<strong>Система координат</strong>
<span>Мир PlayCanvas · Y вверх</span>
</div>
<p>
Коррекция слоёв не меняет координаты камеры, навигации и будущей физики.
</p>
<div className="simulation-viewport__settings-switches">
<div className="simulation-viewport__settings-layer-row">
<Switch
checked={visualInverted}
disabled={state !== "ready"}
label="Инверсия визуального слоя"
onChange={(checked) => {
setVisualInverted(checked);
runtimeRef.current?.setLayerWorldTransform(
"visual",
playCanvasAxisTransform(visualAxis, checked),
);
commitViewerSettings({
...viewerSettingsRef.current,
visual: { ...viewerSettingsRef.current.visual, inverted: checked },
});
}}
/>
<Select
className="simulation-viewport__axis-select"
label="Ось инверсии визуального слоя"
value={visualAxis}
options={AXIS_OPTIONS}
disabled={state !== "ready"}
minMenuWidth={72}
menuWidth={72}
onChange={(axis) => {
setVisualAxis(axis);
runtimeRef.current?.setLayerWorldTransform(
"visual",
playCanvasAxisTransform(axis, visualInverted),
);
commitViewerSettings({
...viewerSettingsRef.current,
visual: { ...viewerSettingsRef.current.visual, axis },
});
}}
/>
</div>
<div className="simulation-viewport__settings-layer-row">
<Switch
checked={collisionInverted}
disabled={state !== "ready"}
label="Инверсия collision-слоя"
onChange={(checked) => {
setCollisionInverted(checked);
runtimeRef.current?.setLayerWorldTransform(
"collision",
playCanvasAxisTransform(collisionAxis, checked),
);
commitViewerSettings({
...viewerSettingsRef.current,
collision: { ...viewerSettingsRef.current.collision, inverted: checked },
});
}}
/>
<Select
className="simulation-viewport__axis-select"
label="Ось инверсии collision-слоя"
value={collisionAxis}
options={AXIS_OPTIONS}
disabled={state !== "ready"}
minMenuWidth={72}
menuWidth={72}
onChange={(axis) => {
setCollisionAxis(axis);
runtimeRef.current?.setLayerWorldTransform(
"collision",
playCanvasAxisTransform(axis, collisionInverted),
);
commitViewerSettings({
...viewerSettingsRef.current,
collision: { ...viewerSettingsRef.current.collision, axis },
});
}}
/>
</div>
</div>
</section>
<section className="simulation-viewport__settings-group">
<div className="simulation-viewport__settings-group-head">
<strong>Управление камерой</strong>
<span>Мышь и тачпад</span>
</div>
<div className="simulation-viewport__settings-switches">
<Switch
checked={horizontalInverted}
disabled={state !== "ready"}
label="Инверсия управления по горизонтали"
onChange={(checked) => {
setHorizontalInverted(checked);
runtimeRef.current?.setCameraInversion("horizontal", checked);
commitViewerSettings({
...viewerSettingsRef.current,
camera: { ...viewerSettingsRef.current.camera, invertHorizontal: checked },
});
}}
/>
<Switch
checked={verticalInverted}
disabled={state !== "ready"}
label="Инверсия управления по вертикали"
onChange={(checked) => {
setVerticalInverted(checked);
runtimeRef.current?.setCameraInversion("vertical", checked);
commitViewerSettings({
...viewerSettingsRef.current,
camera: { ...viewerSettingsRef.current.camera, invertVertical: checked },
});
}}
/>
</div>
</section>
{settingsError ? (
<p className="simulation-viewport__settings-error" role="alert">
Настройки не сохранены: {settingsError}
</p>
) : null}
</div>
) : null}
</div>
</div>
<span className="simulation-viewport__toolbar-balance" aria-hidden="true" />
</header>
<div className="simulation-viewport__stage">
<canvas ref={canvasRef} aria-label={`PlayCanvas сцена ${project.name}`} />
{state !== "ready" ? (
<div className="simulation-viewport__state" role={state === "failed" ? "alert" : "status"}>
{state === "failed" ? null : <ActivityIndicator label="Загрузка сцены" />}
<strong>{state === "failed" ? "Сцена не открылась" : "PlayCanvas загружает мир"}</strong>
<p>{error ?? "Streamed SOG догружается по мере готовности runtime."}</p>
</div>
) : null}
</div>
{!collisionAvailable ? (
<footer className="simulation-viewport__notice">
<StatusBadge tone="warning">Collision недоступен</StatusBadge>
<span>Для этой сборки collision GLB не был запрошен; визуальный слой настоящий и не подменяется.</span>
</footer>
) : collisionState === "failed" ? (
<footer className="simulation-viewport__notice" role="alert">
<StatusBadge tone="warning">Collision не открылся</StatusBadge>
<span>{error ?? "PlayCanvas не смог загрузить collision GLB."}</span>
</footer>
) : null}
</section>
);
}
@@ -0,0 +1,579 @@
import { useEffect, useMemo, useState } from "react";
import {
Button,
FeatureSettingsWindow,
FieldFrame,
Icon,
Select,
SettingsCard,
StatusBadge,
TextField,
WindowFooterActions,
} from "@nodedc/ui-react";
import {
applyComputeContourNetwork,
fetchComputeContourAgentInstall,
fetchComputeContourNetwork,
type ComputeContour,
type ComputeContourAgentInstall,
type ComputeContourDraft,
type ComputeContourNetworkStatus,
type ComputeContourPlatform,
} from "../../core/system/computeContours";
import {
DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS,
parseTelemetryPollIntervalDraft,
resolveTelemetryPollIntervalDraft,
} from "../../core/system/telemetryPollInterval";
interface ComputeContourSettingsWindowProps {
open: boolean;
contour: ComputeContour | null;
mode: "create" | "edit";
onClose: () => void;
onCreate: (draft: ComputeContourDraft) => Promise<ComputeContour>;
onUpdate: (
contour: ComputeContour,
draft: ComputeContourDraft,
) => Promise<ComputeContour>;
}
const platformOptions = [
{ value: "windows", label: "Windows", description: "Worker с Windows service" },
{ value: "linux", label: "Linux", description: "Worker с systemd service" },
{ value: "unknown", label: "Не определено", description: "Платформа будет уточнена" },
] satisfies Array<{
value: ComputeContourPlatform;
label: string;
description: string;
}>;
function emptyDraft(): ComputeContourDraft {
return {
display_name: "",
expected_node_id: "",
platform: "unknown",
telemetry_mode: "agent-mqtt",
address: "",
ssh_port: 22,
mqtt_host: "127.0.0.1",
mqtt_port: 1883,
mqtt_bind_address: "127.0.0.1",
telemetry_poll_interval_seconds: DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS,
mqtt_publish_interval_seconds: 2,
};
}
function draftFromContour(contour: ComputeContour | null): ComputeContourDraft {
if (!contour) return emptyDraft();
return {
display_name: contour.display_name,
expected_node_id: contour.expected_node_id,
platform: contour.platform,
telemetry_mode: contour.telemetry_mode,
address: contour.address,
ssh_port: contour.ssh_port,
mqtt_host: contour.mqtt_host,
mqtt_port: contour.mqtt_port,
mqtt_bind_address: contour.mqtt_bind_address,
telemetry_poll_interval_seconds: contour.telemetry_poll_interval_seconds,
mqtt_publish_interval_seconds: contour.mqtt_publish_interval_seconds,
};
}
export function ComputeContourSettingsWindow({
open,
contour,
mode,
onClose,
onCreate,
onUpdate,
}: ComputeContourSettingsWindowProps) {
const [draft, setDraft] = useState<ComputeContourDraft>(() => draftFromContour(contour));
const [telemetryPollIntervalDraft, setTelemetryPollIntervalDraft] = useState(
() => String(draftFromContour(contour).telemetry_poll_interval_seconds),
);
const [mqttPublishIntervalDraft, setMqttPublishIntervalDraft] = useState(
() => String(draftFromContour(contour).mqtt_publish_interval_seconds),
);
const [activeSection, setActiveSection] = useState<"connection" | "agent">("connection");
const [install, setInstall] = useState<ComputeContourAgentInstall | null>(null);
const [network, setNetwork] = useState<ComputeContourNetworkStatus | null>(null);
const [networkAction, setNetworkAction] = useState<
"refresh" | "broker" | "worker" | null
>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const nextDraft = draftFromContour(mode === "edit" ? contour : null);
setDraft(nextDraft);
setTelemetryPollIntervalDraft(String(nextDraft.telemetry_poll_interval_seconds));
setMqttPublishIntervalDraft(String(nextDraft.mqtt_publish_interval_seconds));
setActiveSection("connection");
setInstall(null);
setNetwork(null);
setNetworkAction(null);
setError(null);
}, [contour, mode, open]);
useEffect(() => {
if (!open || mode !== "edit" || !contour) return;
const controller = new AbortController();
setNetworkAction("refresh");
void fetchComputeContourNetwork(contour.contour_id, controller.signal)
.then((document) => {
if (!controller.signal.aborted) setNetwork(document);
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
setError(reason instanceof Error ? reason.message : "Сетевая проверка недоступна.");
}
})
.finally(() => {
if (!controller.signal.aborted) setNetworkAction(null);
});
return () => controller.abort();
}, [contour, mode, open]);
useEffect(() => {
if (!open || mode !== "edit" || !contour) return;
const controller = new AbortController();
void fetchComputeContourAgentInstall(contour.contour_id, controller.signal)
.then((document) => {
if (!controller.signal.aborted) setInstall(document);
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
setError(reason instanceof Error ? reason.message : "Инструкция агента недоступна.");
}
});
return () => controller.abort();
}, [contour, mode, open]);
const telemetryPollIntervalSeconds = useMemo(
() => parseTelemetryPollIntervalDraft(telemetryPollIntervalDraft),
[telemetryPollIntervalDraft],
);
const mqttPublishIntervalSeconds = useMemo(
() => parseTelemetryPollIntervalDraft(mqttPublishIntervalDraft),
[mqttPublishIntervalDraft],
);
const valid = useMemo(() => (
Boolean(draft.display_name.trim())
&& Boolean(draft.expected_node_id.trim())
&& Boolean(draft.mqtt_host.trim())
&& Boolean(draft.mqtt_bind_address.trim())
&& Number.isInteger(draft.ssh_port)
&& draft.ssh_port > 0
&& Number.isInteger(draft.mqtt_port)
&& draft.mqtt_port > 0
&& telemetryPollIntervalSeconds !== null
&& mqttPublishIntervalSeconds !== null
), [draft, mqttPublishIntervalSeconds, telemetryPollIntervalSeconds]);
const networkProfileDirty = Boolean(
contour
&& (
draft.mqtt_host !== contour.mqtt_host
|| draft.mqtt_port !== contour.mqtt_port
|| draft.mqtt_bind_address !== contour.mqtt_bind_address
|| draft.mqtt_publish_interval_seconds !== contour.mqtt_publish_interval_seconds
),
);
const commitTelemetryPollInterval = () => {
const resolution = resolveTelemetryPollIntervalDraft(
telemetryPollIntervalDraft,
draft.telemetry_poll_interval_seconds,
);
setTelemetryPollIntervalDraft(resolution.draft);
if (!resolution.accepted) return;
setDraft((current) => (
current.telemetry_poll_interval_seconds === resolution.seconds
? current
: {
...current,
telemetry_poll_interval_seconds: resolution.seconds,
}
));
};
const commitMqttPublishInterval = () => {
const resolution = resolveTelemetryPollIntervalDraft(
mqttPublishIntervalDraft,
draft.mqtt_publish_interval_seconds,
);
setMqttPublishIntervalDraft(resolution.draft);
if (!resolution.accepted) return;
setDraft((current) => (
current.mqtt_publish_interval_seconds === resolution.seconds
? current
: {
...current,
mqtt_publish_interval_seconds: resolution.seconds,
}
));
};
const save = async () => {
if (
!valid
|| busy
|| telemetryPollIntervalSeconds === null
|| mqttPublishIntervalSeconds === null
) return;
const committedDraft = {
...draft,
telemetry_poll_interval_seconds: telemetryPollIntervalSeconds,
mqtt_publish_interval_seconds: mqttPublishIntervalSeconds,
};
setBusy(true);
setError(null);
try {
if (mode === "edit" && contour) {
await onUpdate(contour, committedDraft);
} else {
await onCreate(committedDraft);
}
onClose();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Контур не сохранён.");
} finally {
setBusy(false);
}
};
const refreshNetwork = async () => {
if (!contour || networkAction) return;
setNetworkAction("refresh");
setError(null);
try {
setNetwork(await fetchComputeContourNetwork(contour.contour_id));
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Сетевая проверка недоступна.");
} finally {
setNetworkAction(null);
}
};
const applyNetwork = async (target: "broker" | "worker") => {
if (!contour || networkAction || networkProfileDirty) return;
setNetworkAction(target);
setError(null);
try {
await applyComputeContourNetwork(contour.contour_id, target);
setNetwork(await fetchComputeContourNetwork(contour.contour_id));
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Сетевой профиль не применён.",
);
} finally {
setNetworkAction(null);
}
};
return (
<FeatureSettingsWindow
open={open}
title={mode === "create" ? "Добавить вычислительный контур" : "Настройки контура"}
subtitle="Локальная переносимая конфигурация"
identity={{
title: contour?.display_name ?? "Новый контур",
subtitle: contour?.expected_node_id ?? "Node ID не назначен",
avatarLabel: contour?.display_name.slice(0, 2).toUpperCase() ?? "WC",
}}
sections={[
{
id: "connection",
label: "Подключение",
group: "ВЫЧИСЛИТЕЛЬНЫЙ КОНТУР",
icon: "network",
},
{
id: "agent",
label: "Агент",
group: "ТЕЛЕМЕТРИЯ",
icon: "activity",
disabled: mode === "create",
},
]}
activeSection={activeSection}
onSectionChange={setActiveSection}
onClose={onClose}
footer={(
<WindowFooterActions>
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="primary" disabled={!valid || busy} onClick={() => void save()}>
{busy ? "Сохраняем…" : mode === "create" ? "Добавить контур" : "Сохранить"}
</Button>
</WindowFooterActions>
)}
>
<div className="compute-contour-settings">
{activeSection === "connection" ? (
<>
<SettingsCard
eyebrow="КОНТУР"
title="Идентичность и локальная сеть"
description="Node ID проверяется независимо от IP-адреса. Адрес можно менять при переносе комплекта в другую локальную сеть."
>
<div className="compute-contour-settings__form">
<TextField
label="Название"
value={draft.display_name}
placeholder="Worker 006"
onChange={(event) => setDraft((current) => ({
...current,
display_name: event.currentTarget.value,
}))}
/>
<TextField
label="Ожидаемый Node ID"
value={draft.expected_node_id}
placeholder="DESKTOP-OPJ8J04"
onChange={(event) => setDraft((current) => ({
...current,
expected_node_id: event.currentTarget.value,
}))}
/>
<FieldFrame label="Операционная система">
<Select
label="Операционная система"
value={draft.platform}
options={platformOptions}
variant="split"
menuWidth="anchor"
onChange={(platform) => setDraft((current) => ({ ...current, platform }))}
/>
</FieldFrame>
<TextField
label="Адрес узла"
hint="IP или hostname в текущей LAN"
value={draft.address}
onChange={(event) => setDraft((current) => ({
...current,
address: event.currentTarget.value,
}))}
/>
<TextField
label="MQTT broker"
hint="Стабильный hostname рекомендуется вместо выдаваемого DHCP IP"
value={draft.mqtt_host}
onChange={(event) => setDraft((current) => ({
...current,
mqtt_host: event.currentTarget.value,
}))}
/>
<TextField
label="MQTT port"
type="number"
min={1}
max={65535}
value={String(draft.mqtt_port)}
onChange={(event) => setDraft((current) => ({
...current,
mqtt_port: Number(event.currentTarget.value),
}))}
/>
<TextField
label="Публикация broker"
hint="IP текущего Mac в разрешённой LAN · 0.0.0.0 запрещён"
value={draft.mqtt_bind_address}
onChange={(event) => setDraft((current) => ({
...current,
mqtt_bind_address: event.currentTarget.value,
}))}
/>
<TextField
label="Обновление интерфейса"
hint="1–60 с"
type="text"
inputMode="numeric"
autoComplete="off"
aria-invalid={telemetryPollIntervalSeconds === null}
value={telemetryPollIntervalDraft}
onChange={(event) => setTelemetryPollIntervalDraft(event.currentTarget.value)}
onBlur={commitTelemetryPollInterval}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
commitTelemetryPollInterval();
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setTelemetryPollIntervalDraft(
String(draft.telemetry_poll_interval_seconds),
);
}
}}
/>
<TextField
label="Интервал MQTT агента"
hint="1–60 с · применяется конфигурацией агента"
type="text"
inputMode="numeric"
autoComplete="off"
aria-invalid={mqttPublishIntervalSeconds === null}
value={mqttPublishIntervalDraft}
onChange={(event) => setMqttPublishIntervalDraft(event.currentTarget.value)}
onBlur={commitMqttPublishInterval}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
commitMqttPublishInterval();
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setMqttPublishIntervalDraft(
String(draft.mqtt_publish_interval_seconds),
);
}
}}
/>
{draft.telemetry_mode === "legacy-ssh" ? (
<TextField
label="SSH port"
type="number"
min={1}
max={65535}
value={String(draft.ssh_port)}
onChange={(event) => setDraft((current) => ({
...current,
ssh_port: Number(event.currentTarget.value),
}))}
/>
) : null}
</div>
</SettingsCard>
<SettingsCard
eyebrow="СЕТЕВОЙ ПРОФИЛЬ"
title="MQTT · Mac ↔ Worker"
description="Mesh-точки одной LAN не закрепляются по отдельности. Профиль хранит стабильное имя Mac и адрес публикации broker в текущей сети."
actions={(
<StatusBadge tone={network?.broker.ready ? "success" : "warning"}>
{network?.broker.ready ? "Broker доступен" : "Нужна проверка"}
</StatusBadge>
)}
>
<div className="compute-contour-settings__network">
<dl>
<div>
<dt>Endpoint Worker</dt>
<dd>{network
? `${network.broker.configured_host}:${network.broker.configured_port}`
: "—"}</dd>
</div>
<div>
<dt>Текущий IP</dt>
<dd>{network?.broker.resolved_addresses.join(", ") || "—"}</dd>
</div>
<div>
<dt>Публикация Mac</dt>
<dd>{network?.broker.bind_address ?? "—"}</dd>
</div>
<div>
<dt>{contour?.display_name ?? "Worker"}</dt>
<dd>{network?.worker.reachable
? `${network.worker.mqtt_host ?? "—"}:${network.worker.mqtt_port ?? "—"}`
: "Недоступен"}</dd>
</div>
</dl>
<div className="compute-contour-settings__network-statuses">
<StatusBadge tone={network?.broker.bind_reachable ? "success" : "danger"}>
{network?.broker.bind_reachable
? "Listener Mac доступен"
: "Listener Mac недоступен"}
</StatusBadge>
<StatusBadge tone={network?.worker.broker_reachable ? "success" : "danger"}>
{network?.worker.broker_reachable
? "Worker видит broker"
: "Worker не видит broker"}
</StatusBadge>
<StatusBadge tone={network?.worker.matches_profile ? "success" : "warning"}>
{network?.worker.matches_profile
? "Профиль синхронизирован"
: "Профиль отличается"}
</StatusBadge>
</div>
<div className="compute-contour-settings__network-actions">
<Button
size="compact"
variant="secondary"
disabled={Boolean(networkAction)}
icon={<Icon name="refresh" size={14} />}
onClick={() => void refreshNetwork()}
>
{networkAction === "refresh" ? "Проверяем…" : "Проверить"}
</Button>
<Button
size="compact"
variant="secondary"
disabled={Boolean(networkAction) || networkProfileDirty}
onClick={() => void applyNetwork("broker")}
>
{networkAction === "broker" ? "Применяем…" : "Применить на Mac"}
</Button>
<Button
size="compact"
variant="secondary"
disabled={Boolean(networkAction) || networkProfileDirty}
onClick={() => void applyNetwork("worker")}
>
{networkAction === "worker" ? "Применяем…" : "Применить на Worker"}
</Button>
</div>
{networkProfileDirty ? (
<p>Сначала сохраните изменённый сетевой профиль, затем примените его.</p>
) : null}
{error ? <StatusBadge tone="danger">{error}</StatusBadge> : null}
</div>
</SettingsCard>
</>
) : null}
{activeSection === "agent" ? (
<SettingsCard
eyebrow="АГЕНТ"
title="Telegraf · универсальный сборщик"
description="Hardware и runtime собирает готовый агент. Mission Core нормализует только контур, LAB/run и стадии обработки."
actions={(
<StatusBadge tone={install?.ready ? "success" : "warning"}>
{install?.ready ? "Готов к установке" : "Нужен scoped MQTT credential"}
</StatusBadge>
)}
>
{install ? (
<div className="compute-contour-settings__install">
<dl>
<div><dt>Шаблон</dt><dd>{install.agent.configuration_template}</dd></div>
<div><dt>Секрет</dt><dd>интерактивный ввод · не хранится в UI</dd></div>
</dl>
<code>{install.command}</code>
<Button
size="compact"
variant="secondary"
icon={<Icon name="copy" size={14} />}
onClick={() => void navigator.clipboard.writeText(install.command)}
>
Копировать команду
</Button>
{install.blocked_reason ? <p>{install.blocked_reason}</p> : null}
</div>
) : (
<p className="compute-contour-settings__empty">
Сохраните контур, чтобы получить платформенную инструкцию установки.
</p>
)}
{error ? <StatusBadge tone="danger">{error}</StatusBadge> : null}
</SettingsCard>
) : null}
</div>
</FeatureSettingsWindow>
);
}
@@ -0,0 +1,57 @@
import {
AdminNavigationPanel,
Icon,
IconButton,
} from "@nodedc/ui-react";
import { useComputeContours } from "../../core/system/ComputeContourContext";
interface SystemNavigationPanelProps {
title: string;
onAdd: () => void;
onClose: () => void;
}
export function SystemNavigationPanel({
title,
onAdd,
onClose,
}: SystemNavigationPanelProps) {
const { contours, selectedContour, selectContour } = useComputeContours();
const contourCountLabel = contours.length === 1
? "1 конфигурация"
: `${contours.length} конфигураций`;
return (
<AdminNavigationPanel
eyebrow="MISSION CORE"
title={title}
closeLabel={`Закрыть раздел «${title}»`}
navigationLabel="Вычислительные контуры"
onClose={onClose}
headerActions={(
<IconButton label="Добавить вычислительный контур" onClick={onAdd}>
<Icon name="plus" size={16} strokeWidth={1.6} />
</IconButton>
)}
contexts={contours.map((contour) => ({
id: contour.contour_id,
label: contour.display_name,
description: contour.expected_node_id,
icon: <Icon name="apps" />,
active: selectedContour?.contour_id === contour.contour_id,
onSelect: () => selectContour(contour.contour_id),
}))}
items={[]}
onItemChange={() => undefined}
footer={(
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
<Icon name="activity" />
</span>
<span>{contourCountLabel}</span>
</>
)}
/>
);
}
@@ -0,0 +1,33 @@
import { SegmentedControl } from "@nodedc/ui-react";
import { workspacesForRoot } from "../../productModel";
const systemWorkspaces = workspacesForRoot("system");
const compactLabels: Record<string, string> = {
modules: "Модули",
integrations: "Интеграции",
network: "Сеть",
audit: "Аудит",
};
interface SystemWorkspaceSelectorProps {
value: string;
onChange: (workspaceId: string) => void;
}
export function SystemWorkspaceSelector({
value,
onChange,
}: SystemWorkspaceSelectorProps) {
return (
<SegmentedControl
value={value}
items={systemWorkspaces.map((definition) => ({
value: definition.id,
label: compactLabels[definition.id] ?? definition.label,
}))}
label="Рабочая поверхность вычислительного контура"
onChange={onChange}
/>
);
}
@@ -0,0 +1,52 @@
interface TelemetrySeriesProps {
label: string;
values: Array<number | null>;
value: string;
resource?: string | null;
ceiling?: number;
}
function linePoints(values: Array<number | null>, ceiling?: number): string {
const finite = values.filter((value): value is number =>
value !== null && Number.isFinite(value),
);
if (finite.length === 0) return "";
const maximum = Math.max(ceiling ?? 0, ...finite, 1);
const denominator = Math.max(1, values.length - 1);
return values.map((value, index) => {
const x = index / denominator * 100;
const normalized = value === null ? 0 : Math.max(0, Math.min(maximum, value));
const y = 36 - normalized / maximum * 34;
return `${x.toFixed(2)},${y.toFixed(2)}`;
}).join(" ");
}
export function TelemetrySeries({
label,
values,
value,
resource,
ceiling,
}: TelemetrySeriesProps) {
const points = linePoints(values, ceiling);
return (
<div className="system-telemetry-series">
<div>
<span className="system-telemetry-series__label">
<span>{label}</span>
{resource ? <small>{resource}</small> : null}
</span>
<strong>{value}</strong>
</div>
<svg
viewBox="0 0 100 38"
preserveAspectRatio="none"
role="img"
aria-label={`${label}: ${value}`}
>
<path d="M0 37 H100" />
{points ? <polyline points={points} /> : null}
</svg>
</div>
);
}
@@ -0,0 +1,72 @@
import type { CSSProperties } from "react";
import type { WorkerPipelineStage } from "../../core/system/workerTelemetry";
interface WorkerPipelineStagesProps {
stages: WorkerPipelineStage[];
}
function stateLabel(state: WorkerPipelineStage["state"]): string {
if (state === "active") return "выполняется";
if (state === "waiting") return "ожидает";
if (state === "ready") return "готов";
return "нет live-состояния";
}
function stageDuration(value: number | null): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
if (value < 1) return `${Math.round(value * 1000)} мс`;
return `${new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: value < 10 ? 2 : 1,
}).format(value)} с`;
}
function stageDetail(stage: WorkerPipelineStage): string {
if (
typeof stage.activations !== "number"
|| typeof stage.share_percent !== "number"
|| !Number.isFinite(stage.share_percent)
) {
return "Измерений ещё нет";
}
const share = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
}).format(stage.share_percent);
return `${stage.activations} проходов · ${share}% измеренного времени`;
}
export function WorkerPipelineStages({ stages }: WorkerPipelineStagesProps) {
return (
<ol className="worker-stage-list" aria-label="Стадии текущей задачи">
{stages.map((stage, index) => {
const share = typeof stage.share_percent === "number"
? Math.max(0, Math.min(100, stage.share_percent))
: 0;
const style = {
"--worker-stage-share": `${share}%`,
} as CSSProperties;
return (
<li
key={stage.id}
data-state={stage.state}
style={style}
aria-label={`${stage.label}: ${stageDetail(stage)}`}
>
<span className="worker-stage-list__fill" aria-hidden="true" />
<span className="worker-stage-list__index">
{String(index + 1).padStart(2, "0")}
</span>
<div>
<strong>{stage.label}</strong>
<small>{stageDetail(stage)}</small>
</div>
<div className="worker-stage-list__value">
<strong>{stageDuration(stage.elapsed_seconds)}</strong>
<small>{stateLabel(stage.state)}</small>
</div>
</li>
);
})}
</ol>
);
}
@@ -0,0 +1,50 @@
import { GlassSurface, StatusBadge } from "@nodedc/ui-react";
import type { WorkerRuntime } from "../../core/system/workerTelemetry";
import {
formatOptionalPercent,
runtimeStateLabel,
runtimeTone,
} from "./systemFormat";
export function WorkerRuntimeCard({ runtime }: { runtime: WorkerRuntime }) {
return (
<GlassSurface
className="worker-runtime-card"
padding="md"
tone={runtime.external ? "soft" : "default"}
data-external={runtime.external ? "true" : undefined}
>
<header>
<div>
<span className="section-eyebrow">
{runtime.external ? "ВНЕШНЯЯ НАГРУЗКА" : "MISSION CORE RUNTIME"}
</span>
<h3>{runtime.role}</h3>
<code>{runtime.name}</code>
</div>
<StatusBadge tone={runtimeTone(runtime)}>
{runtimeStateLabel(runtime)}
</StatusBadge>
</header>
<dl>
<div>
<dt>CPU</dt>
<dd>{formatOptionalPercent(runtime.cpu_percent)}</dd>
</div>
<div>
<dt>Память</dt>
<dd>{runtime.memory_usage ?? "—"}</dd>
</div>
<div>
<dt>Сеть I/O</dt>
<dd>{runtime.network_io ?? "—"}</dd>
</div>
<div>
<dt>Процессы</dt>
<dd>{runtime.pids ?? "—"}</dd>
</div>
</dl>
</GlassSurface>
);
}
@@ -0,0 +1,67 @@
import type { StatusTone } from "@nodedc/ui-react";
import type { WorkerRuntime } from "../../core/system/workerTelemetry";
export function formatOptionalPercent(value: number | null | undefined): string {
return typeof value === "number" && Number.isFinite(value)
? `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 1 }).format(value)}%`
: "—";
}
export function formatBytes(value: number | null | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
const units = ["Б", "КиБ", "МиБ", "ГиБ", "ТиБ"];
let current = Math.max(0, value);
let unit = 0;
while (current >= 1024 && unit < units.length - 1) {
current /= 1024;
unit += 1;
}
return `${new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: current >= 100 ? 0 : 1,
}).format(current)} ${units[unit]}`;
}
export function formatRate(value: number | null | undefined): string {
const formatted = formatBytes(value);
return formatted === "—" ? formatted : `${formatted}/с`;
}
export function formatBitRate(value: number | null | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
const units = ["бит/с", "Кбит/с", "Мбит/с", "Гбит/с"];
let current = Math.max(0, value);
let unit = 0;
while (current >= 1000 && unit < units.length - 1) {
current /= 1000;
unit += 1;
}
return `${new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: current >= 100 ? 0 : 1,
}).format(current)} ${units[unit]}`;
}
export function formatDuration(value: number | null | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
const hours = Math.floor(value / 3600);
if (hours < 24) return `${hours} ч`;
return `${Math.floor(hours / 24)} д ${hours % 24} ч`;
}
export function formatLatency(value: number | null | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
return `${Math.round(value)} мс`;
}
export function runtimeTone(runtime: WorkerRuntime): StatusTone {
if (runtime.state !== "running") return "danger";
if (runtime.health && runtime.health !== "healthy") return "warning";
return runtime.external ? "neutral" : "success";
}
export function runtimeStateLabel(runtime: WorkerRuntime): string {
if (runtime.state !== "running") return "Недоступен";
if (runtime.health === "healthy") return "Работает";
if (runtime.health) return runtime.health;
return "Запущен";
}
@@ -0,0 +1,62 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import type { ApplicationPanelUtilityAction } from "@nodedc/ui-react";
import { useComputeContours } from "../../core/system/ComputeContourContext";
import { ComputeContourSettingsWindow } from "./ComputeContourSettingsWindow";
interface ComputeContourSettingsController {
canEdit: boolean;
openCreate: () => void;
openEdit: () => void;
utilityActions: readonly ApplicationPanelUtilityAction[];
window: ReactNode;
}
export function useComputeContourSettings(): ComputeContourSettingsController {
const contours = useComputeContours();
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<"create" | "edit">("edit");
const openCreate = useCallback(() => {
setMode("create");
setOpen(true);
}, []);
const openEdit = useCallback(() => {
setMode("edit");
setOpen(true);
}, []);
const utilityActions = useMemo<readonly ApplicationPanelUtilityAction[]>(() => [
{
label: "Обновить телеметрию выбранного контура",
icon: "refresh",
disabled: contours.selectedContour === null,
onClick: contours.refreshTelemetry,
},
{
label: "Настроить выбранный вычислительный контур",
icon: "settings",
disabled: contours.selectedContour === null,
onClick: openEdit,
},
], [
contours.refreshTelemetry,
contours.selectedContour,
openEdit,
]);
return {
canEdit: contours.selectedContour !== null,
openCreate,
openEdit,
utilityActions,
window: (
<ComputeContourSettingsWindow
open={open}
mode={mode}
contour={contours.selectedContour}
onClose={() => setOpen(false)}
onCreate={contours.createContour}
onUpdate={contours.updateContour}
/>
),
};
}
@@ -0,0 +1,81 @@
import { useMemo } from "react";
import type { ApplicationPanelUtilityAction } from "@nodedc/ui-react";
import type { WorkspaceDefinition } from "../productModel";
interface ApplicationPanelActionsOptions {
definition: WorkspaceDefinition | null;
refreshRuntime: () => void;
resetConnectionScenario?: () => Promise<boolean>;
connectionScenarioResetting: boolean;
saveWorkspaceLayout: () => Promise<void>;
workspaceLayoutSaving: boolean;
systemUtilityActions: readonly ApplicationPanelUtilityAction[];
}
export function deviceRuntimeUtilityAction({
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
}: Pick<
ApplicationPanelActionsOptions,
"refreshRuntime" | "resetConnectionScenario" | "connectionScenarioResetting"
>): ApplicationPanelUtilityAction {
return {
label: resetConnectionScenario
? connectionScenarioResetting
? "Сбрасываем подключение"
: "Сбросить подключение"
: "Обновить состояние локального контура",
icon: resetConnectionScenario && connectionScenarioResetting
? "activity"
: "refresh",
disabled: resetConnectionScenario && connectionScenarioResetting
? true
: undefined,
onClick: resetConnectionScenario
? connectionScenarioResetting
? () => undefined
: () => void resetConnectionScenario()
: refreshRuntime,
};
}
export function useApplicationPanelActions({
definition,
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
saveWorkspaceLayout,
workspaceLayoutSaving,
systemUtilityActions,
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
return useMemo(() => {
const actions: ApplicationPanelUtilityAction[] = [];
if (definition?.kind === "device") {
actions.push(deviceRuntimeUtilityAction({
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
}));
}
if (definition && ["spatial", "recordings"].includes(definition.kind)) {
actions.push({
label: workspaceLayoutSaving ? "Сохраняем компоновку" : "Сохранить компоновку",
icon: "save",
disabled: workspaceLayoutSaving,
onClick: () => void saveWorkspaceLayout(),
});
}
if (definition?.root === "system") actions.push(...systemUtilityActions);
return actions;
}, [
definition,
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
saveWorkspaceLayout,
systemUtilityActions,
workspaceLayoutSaving,
]);
}
@@ -0,0 +1,8 @@
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
// Composition root: this is the only place where Mission Core chooses which
// statically reviewed device plugins are shipped in the current build.
export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
xgridsK1Plugin,
]);
@@ -0,0 +1,91 @@
export interface ArtifactScope {
scope_id: string;
file_count: number;
logical_bytes: number;
modified_at_utc: string | null;
}
export interface ArtifactHealth {
schema_version: "missioncore.artifact-health/v1";
generated_at_utc: string;
overall_state: "live" | "attention" | "unavailable";
inventory: {
state: "live" | "degraded" | "unavailable";
scanned_at_utc: string;
scan_duration_ms: number;
file_count: number;
logical_bytes: number;
unreadable_entries: number;
files_delta_since_previous: number | null;
bytes_delta_since_previous: number | null;
scopes: ArtifactScope[];
};
exact_audit: {
state: "exact-current" | "exact-stale" | "unavailable";
result_id: string | null;
audited_at_utc: string | null;
root_count: number;
file_count: number;
logical_bytes: number;
unique_content_bytes: number;
duplicate_bytes: number;
duplicate_fraction: number;
amplification_ratio: number;
duplicate_content_groups: number;
changed_roots: string[];
limitations: string[];
};
content_references: {
state: "not-indexed" | "indexed";
storage_model:
| "materialized-copies"
| "materialized-copies-with-reference-index";
result_id: string | null;
indexed_at_utc: string | null;
logical_reference_count: number;
canonical_content_count: number;
duplicate_reference_count: number;
exact_duplicate_bytes: number;
physical_reclamation_applied: false;
storage_migration_authorized: false;
next_gate: string;
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export async function fetchArtifactHealth(
signal?: AbortSignal,
): Promise<ArtifactHealth> {
const response = await fetch("/api/v1/data/artifact-health", {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) {
throw new Error(`API здоровья данных вернул HTTP ${response.status}.`);
}
const document: unknown = await response.json();
if (
!isRecord(document)
|| document.schema_version !== "missioncore.artifact-health/v1"
|| !isRecord(document.inventory)
|| !Array.isArray(document.inventory.scopes)
|| !isRecord(document.exact_audit)
|| !isRecord(document.content_references)
|| !["not-indexed", "indexed"].includes(
String(document.content_references.state),
)
|| ![
"materialized-copies",
"materialized-copies-with-reference-index",
].includes(String(document.content_references.storage_model))
|| document.content_references.physical_reclamation_applied !== false
|| document.content_references.storage_migration_authorized !== false
) {
throw new Error("Ответ здоровья данных не соответствует контракту.");
}
return document as unknown as ArtifactHealth;
}
@@ -0,0 +1,227 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
interface DevicePluginHostValue {
registry: DevicePluginRegistry;
selection: RegisteredDeviceModel | null;
selectionTransitionPending: boolean;
selectionTransitionError: string | null;
selectModel: (modelId: string) => Promise<boolean>;
clearSelection: () => Promise<boolean>;
}
const DevicePluginHostContext = createContext<DevicePluginHostValue | null>(null);
export const DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY =
"nodedc.mission-core.device-model-selection.v1";
export interface DeviceModelSelectionStorage {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
}
function browserDeviceModelSelectionStorage(): DeviceModelSelectionStorage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
/**
* Restore only a model that is present in the current reviewed registry.
* Removed/renamed models and malformed browser values fail closed to the
* picker and are cleared so a later remount cannot keep retrying stale state.
*/
export function restorePersistedDeviceModelId(
registry: DevicePluginRegistry,
storage: DeviceModelSelectionStorage | null,
): string | null {
if (!storage) return null;
try {
const stored = storage.getItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
const modelId = stored?.trim() || null;
if (modelId && registry.resolveModel(modelId)) return modelId;
if (stored !== null) {
storage.removeItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
}
return null;
} catch {
return null;
}
}
/** Persist only an already-admitted host transition; browser storage is never authority. */
export function commitPersistedDeviceModelId(
modelId: string | null,
storage: DeviceModelSelectionStorage | null,
): void {
if (!storage) return;
try {
if (modelId === null) {
storage.removeItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
return;
}
storage.setItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY, modelId);
} catch {
// A denied/full localStorage must not block the in-memory host transition.
}
}
export function DevicePluginHostProvider({
plugins,
children,
}: {
plugins: readonly DeviceUiPlugin[];
children: ReactNode;
}) {
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []);
const [selectedModelId, setSelectedModelId] = useState<string | null>(() =>
restorePersistedDeviceModelId(registry, selectionStorage)
);
const [selectionTransitionPending, setSelectionTransitionPending] = useState(false);
const [selectionTransitionError, setSelectionTransitionError] = useState<string | null>(null);
const transitionInFlight = useRef(false);
const deactivationHandlers = useRef(new Map<string, () => Promise<boolean>>());
const selection = selectedModelId ? registry.resolveModel(selectedModelId) : null;
const registerDeactivation = useCallback(
(pluginId: string, handler: () => Promise<boolean>) => {
deactivationHandlers.current.set(pluginId, handler);
return () => {
if (deactivationHandlers.current.get(pluginId) === handler) {
deactivationHandlers.current.delete(pluginId);
}
};
},
[],
);
const transitionTo = useCallback(
async (nextModelId: string | null): Promise<boolean> => {
if (transitionInFlight.current) return false;
if (nextModelId !== null && !registry.resolveModel(nextModelId)) {
throw new Error(`Модель устройства не зарегистрирована: ${nextModelId}.`);
}
if (nextModelId === selectedModelId) {
// `clearSelection()` must clear a stale persisted value even when the
// current in-memory selection is already empty.
commitPersistedDeviceModelId(nextModelId, selectionStorage);
return true;
}
transitionInFlight.current = true;
setSelectionTransitionPending(true);
setSelectionTransitionError(null);
try {
const current = selectedModelId ? registry.resolveModel(selectedModelId) : null;
const deactivate = current
? deactivationHandlers.current.get(current.plugin.manifest.metadata.id)
: undefined;
if (current && !deactivate) {
setSelectionTransitionError(
"Активный плагин не зарегистрировал безопасное завершение сессии.",
);
return false;
}
if (deactivate) {
try {
if (!(await deactivate())) {
setSelectionTransitionError(
"Плагин не подтвердил завершение текущей сессии.",
);
return false;
}
} catch {
setSelectionTransitionError(
"Не удалось безопасно завершить текущую сессию плагина.",
);
return false;
}
}
setSelectedModelId(nextModelId);
commitPersistedDeviceModelId(nextModelId, selectionStorage);
return true;
} finally {
transitionInFlight.current = false;
setSelectionTransitionPending(false);
}
},
[registry, selectedModelId, selectionStorage],
);
const deactivationRegistrars = useMemo(
() =>
new Map(
registry.plugins.map((plugin) => [
plugin.manifest.metadata.id,
(handler: () => Promise<boolean>) =>
registerDeactivation(plugin.manifest.metadata.id, handler),
]),
),
[registerDeactivation, registry.plugins],
);
const value = useMemo<DevicePluginHostValue>(
() => ({
registry,
selection,
selectionTransitionPending,
selectionTransitionError,
selectModel: (modelId) => transitionTo(modelId),
clearSelection: () => transitionTo(null),
}),
[
registry,
selection,
selectionTransitionError,
selectionTransitionPending,
transitionTo,
],
);
const runtimeTree = [...registry.plugins].reverse().reduce<ReactNode>((child, plugin) => {
const RuntimeProvider = plugin.RuntimeProvider;
return (
<RuntimeProvider
key={plugin.manifest.metadata.id}
activeModel={
selection?.plugin.manifest.metadata.id === plugin.manifest.metadata.id
? selection.model
: null
}
registerDeactivation={deactivationRegistrars.get(plugin.manifest.metadata.id)!}
>
{child}
</RuntimeProvider>
);
}, children);
return (
<DevicePluginHostContext.Provider value={value}>
<IdleMissionRuntimeProvider>{runtimeTree}</IdleMissionRuntimeProvider>
</DevicePluginHostContext.Provider>
);
}
export function useDevicePluginHost(): DevicePluginHostValue {
const value = useContext(DevicePluginHostContext);
if (!value) {
throw new Error("DevicePluginHostProvider не подключён в composition root.");
}
return value;
}
@@ -0,0 +1,121 @@
import type { ComponentType, ReactNode } from "react";
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA1 = "missioncore.nodedc/v1alpha1" as const;
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA2 = "missioncore.nodedc/v1alpha2" as const;
export const DEVICE_PLUGIN_API_VERSION = DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
export const SUPPORTED_DEVICE_PLUGIN_API_VERSIONS = Object.freeze([
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
] as const);
export const DEVICE_STATE_READ_ACTION_ID = "state.read" as const;
export type DevicePluginApiVersion =
(typeof SUPPORTED_DEVICE_PLUGIN_API_VERSIONS)[number];
export interface DeviceCapability {
id: string;
label: string;
}
export interface DevicePluginActionDefinition {
id: string;
mutating: boolean;
secretFields: readonly string[];
}
export interface DeviceModelDefinition {
id: string;
vendor: string;
displayName: string;
category: string;
description: string;
verified: boolean;
capabilities: readonly DeviceCapability[];
ui: {
slot: "device.connection";
componentKey: string;
};
}
export interface DeviceCompatibilityProfileDefinition {
profileId: string;
path: string;
modelId: string;
}
interface DevicePluginManifestBase {
apiVersion: DevicePluginApiVersion;
kind: "DevicePlugin";
metadata: {
id: string;
version: string;
displayName: string;
};
}
export interface DevicePluginManifestV1Alpha1 extends DevicePluginManifestBase {
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA1;
spec: {
hostApiRange: "v1alpha1";
runtime: {
backendEntrypoint: string;
isolation: "transitional-in-process";
};
permissions: readonly string[];
actions: readonly DevicePluginActionDefinition[];
models: readonly DeviceModelDefinition[];
};
}
export interface DevicePluginManifestV1Alpha2 extends DevicePluginManifestBase {
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
spec: {
hostApiRange: "v1alpha2";
runtime: {
backendEntrypoint: string;
isolation: "transitional-in-process";
};
permissions: readonly string[];
actions: readonly DevicePluginActionDefinition[];
models: readonly DeviceModelDefinition[];
compatibilityProfiles: readonly DeviceCompatibilityProfileDefinition[];
};
}
export type DevicePluginManifest =
| DevicePluginManifestV1Alpha1
| DevicePluginManifestV1Alpha2;
export function isDevicePluginManifestV1Alpha2(
manifest: DevicePluginManifest,
): manifest is DevicePluginManifestV1Alpha2 {
return manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
}
export interface DevicePluginHostActions {
openSpatialScene: () => void;
activateAutomaticSpatialSource: () => void;
}
export interface DevicePluginConnectionProps {
model: DeviceModelDefinition;
host: DevicePluginHostActions;
}
export interface DeviceUiPlugin {
manifest: DevicePluginManifest;
RuntimeProvider: ComponentType<{
activeModel: DeviceModelDefinition | null;
registerDeactivation: (handler: () => Promise<boolean>) => () => void;
children: ReactNode;
}>;
connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>;
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
}
export interface RegisteredDeviceModel {
plugin: DeviceUiPlugin;
model: DeviceModelDefinition;
ConnectionView: ComponentType<DevicePluginConnectionProps>;
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
}
@@ -0,0 +1,14 @@
/**
* Public frontend host surface for statically reviewed device plugins.
*
* Plugin source may depend on this module through the
* `@mission-core/plugin-sdk` alias. It must not reach into Control Station
* implementation paths directly.
*/
export * from "./contracts";
export { parseDevicePluginManifest, requirePluginAction } from "./manifestParser";
export {
MissionRuntimeProvider,
useMissionRuntime,
} from "../runtime/MissionRuntimeContext";
export type * from "../runtime/contracts";
@@ -0,0 +1,313 @@
import {
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
type DeviceCapability,
type DeviceCompatibilityProfileDefinition,
type DeviceModelDefinition,
type DevicePluginActionDefinition,
type DevicePluginManifest,
type DevicePluginManifestV1Alpha1,
type DevicePluginManifestV1Alpha2,
} from "./contracts";
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`Некорректный manifest: ${path} должен быть объектом.`);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: Record<string, unknown>,
path: string,
allowed: readonly string[],
): void {
const extras = Object.keys(value).filter((key) => !allowed.includes(key));
if (extras.length) {
throw new Error(`Некорректный manifest: ${path} содержит неизвестные поля ${extras.join(", ")}.`);
}
}
function text(value: unknown, path: string, maxLength = 160): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`Некорректный manifest: ${path} должен быть непустой строкой.`);
}
if (value.length > maxLength) {
throw new Error(
`Некорректный manifest: ${path} длиннее ${maxLength} символов.`,
);
}
return value;
}
function identifier(value: unknown, path: string): string {
const candidate = text(value, path, 192);
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(candidate)) {
throw new Error(`Некорректный manifest: ${path} не является идентификатором.`);
}
return candidate;
}
function flag(value: unknown, path: string): boolean {
if (typeof value !== "boolean") {
throw new Error(`Некорректный manifest: ${path} должен быть boolean.`);
}
return value;
}
function list(value: unknown, path: string): unknown[] {
if (!Array.isArray(value)) {
throw new Error(`Некорректный manifest: ${path} должен быть массивом.`);
}
return value;
}
function contractText(value: unknown, path: string, strictIdentifiers: boolean): string {
return strictIdentifiers ? identifier(value, path) : text(value, path);
}
function capability(
value: unknown,
path: string,
strictIdentifiers: boolean,
): DeviceCapability {
const item = record(value, path);
exactKeys(item, path, ["id", "label"]);
return {
id: contractText(item.id, `${path}.id`, strictIdentifiers),
label: text(item.label, `${path}.label`),
};
}
function action(
value: unknown,
path: string,
strictIdentifiers: boolean,
): DevicePluginActionDefinition {
const item = record(value, path);
exactKeys(item, path, ["id", "mutating", "secretFields"]);
return {
id: contractText(item.id, `${path}.id`, strictIdentifiers),
mutating: flag(item.mutating, `${path}.mutating`),
secretFields: list(item.secretFields, `${path}.secretFields`).map((field, index) =>
contractText(field, `${path}.secretFields[${index}]`, strictIdentifiers),
),
};
}
function model(
value: unknown,
path: string,
strictIdentifiers: boolean,
): DeviceModelDefinition {
const item = record(value, path);
exactKeys(item, path, [
"id",
"vendor",
"displayName",
"category",
"description",
"verified",
"capabilities",
"ui",
]);
const ui = record(item.ui, `${path}.ui`);
exactKeys(ui, `${path}.ui`, ["slot", "componentKey"]);
const slot = text(ui.slot, `${path}.ui.slot`);
if (slot !== "device.connection") {
throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`);
}
return {
id: contractText(item.id, `${path}.id`, strictIdentifiers),
vendor: text(item.vendor, `${path}.vendor`),
displayName: text(item.displayName, `${path}.displayName`),
category: text(item.category, `${path}.category`),
description: text(item.description, `${path}.description`, 1024),
verified: flag(item.verified, `${path}.verified`),
capabilities: list(item.capabilities, `${path}.capabilities`).map((entry, index) =>
capability(entry, `${path}.capabilities[${index}]`, strictIdentifiers),
),
ui: {
slot,
componentKey: text(ui.componentKey, `${path}.ui.componentKey`),
},
};
}
function compatibilityProfile(
value: unknown,
path: string,
): DeviceCompatibilityProfileDefinition {
const item = record(value, path);
exactKeys(item, path, ["profileId", "path", "modelId"]);
const profilePath = text(item.path, `${path}.path`, 512);
const pathSegments = profilePath.split("/");
if (
profilePath.startsWith("/") ||
profilePath.includes("\\") ||
!profilePath.endsWith(".json") ||
pathSegments.some((segment) => !segment || segment === "." || segment === "..")
) {
throw new Error(
`Некорректный manifest: ${path}.path должен быть безопасным относительным JSON-путём.`,
);
}
return {
profileId: identifier(item.profileId, `${path}.profileId`),
path: profilePath,
modelId: identifier(item.modelId, `${path}.modelId`),
};
}
function validateV1Alpha2Profiles(
models: readonly DeviceModelDefinition[],
profiles: readonly DeviceCompatibilityProfileDefinition[],
): void {
if (!profiles.length) {
throw new Error("Manifest v1alpha2 должен объявлять compatibilityProfiles.");
}
const modelIds = new Set(models.map((modelItem) => modelItem.id));
const profileIds = new Set<string>();
const profilePaths = new Set<string>();
const coveredModels = new Set<string>();
for (const profile of profiles) {
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
throw new Error(
`Manifest v1alpha2 повторяет профиль ${profile.profileId} или его путь.`,
);
}
profileIds.add(profile.profileId);
profilePaths.add(profile.path);
if (!modelIds.has(profile.modelId)) {
throw new Error(
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
);
}
coveredModels.add(profile.modelId);
}
const uncovered = [...modelIds].filter((modelId) => !coveredModels.has(modelId));
if (uncovered.length) {
throw new Error(
`Manifest v1alpha2 не содержит разрешённого профиля для моделей: ${uncovered.join(", ")}.`,
);
}
}
export function parseDevicePluginManifest(document: unknown): DevicePluginManifest {
const root = record(document, "root");
exactKeys(root, "root", ["apiVersion", "kind", "metadata", "spec"]);
const apiVersion = root.apiVersion;
if (
(apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA2) ||
root.kind !== "DevicePlugin"
) {
throw new Error("Manifest использует несовместимую версию или kind.");
}
const metadata = record(root.metadata, "metadata");
exactKeys(metadata, "metadata", ["id", "version", "displayName"]);
const spec = record(root.spec, "spec");
const specKeys = [
"hostApiRange",
"runtime",
"permissions",
"actions",
"models",
];
exactKeys(
spec,
"spec",
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2
? [...specKeys, "compatibilityProfiles"]
: specKeys,
);
const expectedHostApiRange =
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 ? "v1alpha2" : "v1alpha1";
if (spec.hostApiRange !== expectedHostApiRange) {
throw new Error(`Manifest требует несовместимый host API: ${String(spec.hostApiRange)}.`);
}
const runtime = record(spec.runtime, "spec.runtime");
exactKeys(runtime, "spec.runtime", ["backendEntrypoint", "isolation"]);
const isolation = text(runtime.isolation, "spec.runtime.isolation");
if (isolation !== "transitional-in-process") {
throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`);
}
const strictIdentifiers = apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
const models = list(spec.models, "spec.models").map((entry, index) =>
model(entry, `spec.models[${index}]`, strictIdentifiers),
);
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 && models.length !== 1) {
throw new Error("Manifest v1alpha1 должен объявлять ровно одну модель.");
}
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 && !models.length) {
throw new Error("Manifest v1alpha2 должен объявлять хотя бы одну модель.");
}
const version = text(metadata.version, "metadata.version");
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
throw new Error(`Некорректный manifest: версия ${version} не является semver.`);
}
const common = {
kind: "DevicePlugin",
metadata: {
id: contractText(metadata.id, "metadata.id", strictIdentifiers),
version,
displayName: text(metadata.displayName, "metadata.displayName"),
},
} as const;
const commonSpec = {
runtime: {
backendEntrypoint: text(
runtime.backendEntrypoint,
"spec.runtime.backendEntrypoint",
256,
),
isolation,
},
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
contractText(entry, `spec.permissions[${index}]`, strictIdentifiers),
),
actions: list(spec.actions, "spec.actions").map((entry, index) =>
action(entry, `spec.actions[${index}]`, strictIdentifiers),
),
models,
} as const;
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1) {
return {
...common,
apiVersion,
spec: {
...commonSpec,
hostApiRange: "v1alpha1",
},
} satisfies DevicePluginManifestV1Alpha1;
}
const profiles = list(spec.compatibilityProfiles, "spec.compatibilityProfiles").map(
(entry, index) => compatibilityProfile(entry, `spec.compatibilityProfiles[${index}]`),
);
validateV1Alpha2Profiles(models, profiles);
return {
...common,
apiVersion,
spec: {
...commonSpec,
hostApiRange: "v1alpha2",
compatibilityProfiles: profiles,
},
} satisfies DevicePluginManifestV1Alpha2;
}
export function requirePluginAction(
manifest: DevicePluginManifest,
actionId: string,
): string {
const action = manifest.spec.actions.find((candidate) => candidate.id === actionId);
if (!action) {
throw new Error(`Плагин ${manifest.metadata.id} не объявляет действие ${actionId}.`);
}
return action.id;
}
@@ -0,0 +1,141 @@
import {
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
DEVICE_STATE_READ_ACTION_ID,
SUPPORTED_DEVICE_PLUGIN_API_VERSIONS,
isDevicePluginManifestV1Alpha2,
type DeviceUiPlugin,
type RegisteredDeviceModel,
} from "./contracts";
export interface DevicePluginRegistry {
readonly plugins: readonly DeviceUiPlugin[];
readonly models: readonly RegisteredDeviceModel[];
resolveModel: (modelId: string) => RegisteredDeviceModel | null;
}
export function createDevicePluginRegistry(
installedPlugins: readonly DeviceUiPlugin[],
): DevicePluginRegistry {
const pluginIds = new Set<string>();
const modelIds = new Set<string>();
const models: RegisteredDeviceModel[] = [];
for (const plugin of installedPlugins) {
const { manifest } = plugin;
if (!SUPPORTED_DEVICE_PLUGIN_API_VERSIONS.includes(manifest.apiVersion)) {
throw new Error(
`Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`,
);
}
if (manifest.kind !== "DevicePlugin") {
throw new Error(`Неподдерживаемый kind плагина: ${manifest.kind}.`);
}
if (!manifest.metadata.id.trim() || pluginIds.has(manifest.metadata.id)) {
throw new Error(
`Идентификатор плагина пуст или повторяется: ${manifest.metadata.id || "<empty>"}.`,
);
}
pluginIds.add(manifest.metadata.id);
if (
manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
manifest.spec.models.length !== 1
) {
throw new Error(
`Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`,
);
}
if (!manifest.spec.models.length) {
throw new Error(`Плагин ${manifest.metadata.id} не объявляет ни одной модели.`);
}
const permissions = new Set(manifest.spec.permissions);
if (permissions.size !== manifest.spec.permissions.length) {
throw new Error(`Плагин ${manifest.metadata.id} повторяет permission.`);
}
const actionIds = new Set<string>();
for (const action of manifest.spec.actions) {
if (!action.id.trim() || actionIds.has(action.id)) {
throw new Error(
`Идентификатор действия пуст или повторяется: ${action.id || "<empty>"}.`,
);
}
actionIds.add(action.id);
if (new Set(action.secretFields).size !== action.secretFields.length) {
throw new Error(`Действие ${action.id} повторяет secret field.`);
}
}
const stateRead = manifest.spec.actions.find(
(action) => action.id === DEVICE_STATE_READ_ACTION_ID,
);
if (!stateRead || stateRead.mutating || stateRead.secretFields.length) {
throw new Error(
`Плагин ${manifest.metadata.id} должен объявлять безопасное действие state.read.`,
);
}
for (const model of manifest.spec.models) {
if (!model.id.trim() || modelIds.has(model.id)) {
throw new Error(`Идентификатор модели пуст или повторяется: ${model.id || "<empty>"}.`);
}
modelIds.add(model.id);
const capabilityIds = new Set(model.capabilities.map((capability) => capability.id));
if (capabilityIds.size !== model.capabilities.length) {
throw new Error(`Модель ${model.id} повторяет capability.`);
}
const ConnectionView = plugin.connectionViews[model.ui.componentKey];
if (!ConnectionView) {
throw new Error(
`Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`,
);
}
models.push({
plugin,
model,
ConnectionView,
...(plugin.SpatialControlsView
? { SpatialControlsView: plugin.SpatialControlsView }
: {}),
});
}
if (isDevicePluginManifestV1Alpha2(manifest)) {
const profileIds = new Set<string>();
const profilePaths = new Set<string>();
const declaredModelIds = new Set(manifest.spec.models.map((model) => model.id));
const coveredModelIds = new Set<string>();
if (!manifest.spec.compatibilityProfiles.length) {
throw new Error(`Плагин ${manifest.metadata.id} не объявляет compatibilityProfiles.`);
}
for (const profile of manifest.spec.compatibilityProfiles) {
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
throw new Error(`Плагин ${manifest.metadata.id} повторяет compatibility profile.`);
}
profileIds.add(profile.profileId);
profilePaths.add(profile.path);
if (!declaredModelIds.has(profile.modelId)) {
throw new Error(
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
);
}
coveredModelIds.add(profile.modelId);
}
const uncovered = [...declaredModelIds].filter(
(modelId) => !coveredModelIds.has(modelId),
);
if (uncovered.length) {
throw new Error(
`Плагин ${manifest.metadata.id} не имеет разрешённого профиля для ${uncovered.join(", ")}.`,
);
}
}
}
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
return {
plugins: Object.freeze([...installedPlugins]),
models: Object.freeze(models),
resolveModel: (modelId) => modelById.get(modelId) ?? null,
};
}
@@ -0,0 +1,348 @@
import { roots, type RootId } from "../../productModel";
export type EnvironmentSurfaceId = "home" | RootId;
export type EnvironmentMediaKind = "image" | "video";
export type EnvironmentMediaSource = "file" | "url";
export interface EnvironmentMediaItem {
id: string;
source: EnvironmentMediaSource;
url: string | null;
mediaKind: EnvironmentMediaKind | null;
fileName: string | null;
}
export interface EnvironmentBackground {
enabled: boolean;
imageDurationSeconds: number;
items: EnvironmentMediaItem[];
}
export interface EnvironmentPage {
headerLabel: string;
eyebrow: string;
title: string;
description: string;
primaryWorkspaceId: string | null;
secondaryWorkspaceId: string | null;
background: EnvironmentBackground;
}
export interface EnvironmentSettings {
revision: number;
pages: Record<EnvironmentSurfaceId, EnvironmentPage>;
}
export interface UploadedEnvironmentMedia {
surfaceId: EnvironmentSurfaceId;
itemId: string;
url: string;
fileName: string;
mediaKind: EnvironmentMediaKind;
mediaType: string;
byteLength: number;
sha256: string;
}
const surfaceIds: readonly EnvironmentSurfaceId[] = [
"home",
"fleet",
"observation",
"missions",
"data",
"system",
"polygon",
];
export const defaultEnvironmentImageDurationSeconds = 10;
export const maxEnvironmentMediaItems = 24;
function emptyBackground(): EnvironmentBackground {
return {
enabled: false,
imageDurationSeconds: defaultEnvironmentImageDurationSeconds,
items: [],
};
}
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
return {
id: `media-${crypto.randomUUID()}`,
source: "file",
url: null,
mediaKind: null,
fileName: null,
};
}
export function appendEnvironmentMediaItem(
background: EnvironmentBackground,
item: EnvironmentMediaItem = createEnvironmentMediaItem(),
): EnvironmentBackground {
if (
background.items.length >= maxEnvironmentMediaItems
|| background.items.some((candidate) => candidate.id === item.id)
) {
return background;
}
return {
...background,
enabled: background.items.length === 0 ? true : background.enabled,
items: [...background.items, item],
};
}
export function removeEnvironmentMediaItem(
background: EnvironmentBackground,
itemId: string,
): EnvironmentBackground {
const items = background.items.filter((item) => item.id !== itemId);
if (items.length === background.items.length) return background;
return {
...background,
enabled: items.length > 0 && background.enabled,
items,
};
}
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
}
const defaultQuickActions: Record<
EnvironmentSurfaceId,
readonly [string | null, string | null]
> = {
home: ["spatial-scene", "local-device"],
fleet: ["contour-health", "local-device"],
observation: ["spatial-scene", "cameras"],
missions: ["mission-planner", null],
data: ["recordings", "datasets"],
system: ["modules", "integrations"],
polygon: ["lab-archive", null],
};
export function defaultEnvironmentSettings(): EnvironmentSettings {
const rootById = new Map(roots.map((root) => [root.id, root]));
return {
revision: 0,
pages: Object.fromEntries(surfaceIds.map((surfaceId) => {
const root = surfaceId === "home" ? null : rootById.get(surfaceId) ?? null;
const [primaryWorkspaceId, secondaryWorkspaceId] = defaultQuickActions[surfaceId];
return [surfaceId, {
headerLabel: root?.label ?? "Mission Core",
eyebrow: root?.eyebrow ?? "NODEDC / MISSION CORE",
title: root?.title ?? "Mission Core",
description: root?.statement
?? "Наблюдение, планирование и корректировка миссий в одной модульной рабочей области.",
primaryWorkspaceId,
secondaryWorkspaceId,
background: emptyBackground(),
}];
})) as Record<EnvironmentSurfaceId, EnvironmentPage>,
};
}
function requireRecord(value: unknown, path: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${path} должен быть объектом.`);
}
return value as Record<string, unknown>;
}
function requireString(
value: unknown,
path: string,
nullable = false,
): string | null {
if (nullable && value === null) return null;
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${path} должен быть непустой строкой.`);
}
return value;
}
function decodeMediaItem(value: unknown, path: string): EnvironmentMediaItem {
const record = requireRecord(value, path);
if (
typeof record.id !== "string"
|| !/^media-[a-z0-9-]{1,58}$/.test(record.id)
) {
throw new Error(`${path}.id не поддерживается.`);
}
if (!["file", "url"].includes(String(record.source))) {
throw new Error(`${path}.source не поддерживается.`);
}
if (!["image", "video"].includes(String(record.media_kind))) {
throw new Error(`${path}.media_kind не поддерживается.`);
}
return {
id: record.id,
source: record.source as EnvironmentMediaSource,
url: requireString(record.url, `${path}.url`),
mediaKind: record.media_kind as EnvironmentMediaKind,
fileName: requireString(record.file_name, `${path}.file_name`, true),
};
}
function decodeBackground(value: unknown, path: string): EnvironmentBackground {
const record = requireRecord(value, path);
if (typeof record.enabled !== "boolean") {
throw new Error(`${path}.enabled должен быть boolean.`);
}
if (
typeof record.image_duration_seconds !== "number"
|| !Number.isSafeInteger(record.image_duration_seconds)
|| record.image_duration_seconds < 1
|| record.image_duration_seconds > 300
) {
throw new Error(`${path}.image_duration_seconds некорректен.`);
}
if (
!Array.isArray(record.items)
|| record.items.length > maxEnvironmentMediaItems
) {
throw new Error(`${path}.items некорректен.`);
}
const items = record.items.map((item, index) =>
decodeMediaItem(item, `${path}.items[${index}]`));
if (new Set(items.map((item) => item.id)).size !== items.length) {
throw new Error(`${path}.items содержит повторяющиеся id.`);
}
return {
enabled: record.enabled,
imageDurationSeconds: record.image_duration_seconds,
items,
};
}
function decodePage(value: unknown, path: string): EnvironmentPage {
const record = requireRecord(value, path);
return {
headerLabel: requireString(record.header_label, `${path}.header_label`)!,
eyebrow: requireString(record.eyebrow, `${path}.eyebrow`)!,
title: requireString(record.title, `${path}.title`)!,
description: requireString(record.description, `${path}.description`)!,
primaryWorkspaceId: requireString(
record.primary_workspace_id,
`${path}.primary_workspace_id`,
true,
),
secondaryWorkspaceId: requireString(
record.secondary_workspace_id,
`${path}.secondary_workspace_id`,
true,
),
background: decodeBackground(record.background, `${path}.background`),
};
}
export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
const record = requireRecord(value, "environment");
if (record.schema_version !== "missioncore.operator-environment/v3") {
throw new Error("Версия настроек окружения не поддерживается.");
}
if (
typeof record.revision !== "number"
|| !Number.isSafeInteger(record.revision)
|| record.revision < 0
) {
throw new Error("Ревизия настроек окружения некорректна.");
}
const pages = requireRecord(record.pages, "environment.pages");
return {
revision: record.revision,
pages: Object.fromEntries(surfaceIds.map((surfaceId) => [
surfaceId,
decodePage(pages[surfaceId], `environment.pages.${surfaceId}`),
])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
};
}
export function encodeEnvironmentSettings(settings: EnvironmentSettings): unknown {
return {
revision: settings.revision,
pages: Object.fromEntries(surfaceIds.map((surfaceId) => {
const page = settings.pages[surfaceId];
return [surfaceId, {
header_label: page.headerLabel,
eyebrow: page.eyebrow,
title: page.title,
description: page.description,
primary_workspace_id: page.primaryWorkspaceId,
secondary_workspace_id: page.secondaryWorkspaceId,
background: {
enabled: page.background.enabled,
image_duration_seconds: page.background.imageDurationSeconds,
items: page.background.items.map((item) => ({
id: item.id,
source: item.source,
url: item.url,
media_kind: item.mediaKind,
file_name: item.fileName,
})),
},
}];
})),
};
}
export function decodeUploadedEnvironmentMedia(
value: unknown,
): UploadedEnvironmentMedia {
const record = requireRecord(value, "environment media");
if (record.schema_version !== "missioncore.operator-environment-media/v2") {
throw new Error("Версия загруженного media не поддерживается.");
}
if (!surfaceIds.includes(record.surface_id as EnvironmentSurfaceId)) {
throw new Error("Экран загруженного media не поддерживается.");
}
if (
typeof record.item_id !== "string"
|| !/^media-[a-z0-9-]{1,58}$/.test(record.item_id)
) {
throw new Error("Идентификатор загруженного media не поддерживается.");
}
if (!["image", "video"].includes(String(record.media_kind))) {
throw new Error("Тип загруженного media не поддерживается.");
}
if (
typeof record.byte_length !== "number"
|| !Number.isSafeInteger(record.byte_length)
|| record.byte_length <= 0
) {
throw new Error("Размер загруженного media некорректен.");
}
return {
surfaceId: record.surface_id as EnvironmentSurfaceId,
itemId: record.item_id,
url: requireString(record.url, "environment media.url")!,
fileName: requireString(record.file_name, "environment media.file_name")!,
mediaKind: record.media_kind as EnvironmentMediaKind,
mediaType: requireString(record.media_type, "environment media.media_type")!,
byteLength: record.byte_length,
sha256: requireString(record.sha256, "environment media.sha256")!,
};
}
export function cloneEnvironmentSettings(
settings: EnvironmentSettings,
): EnvironmentSettings {
return {
revision: settings.revision,
pages: Object.fromEntries(surfaceIds.map((surfaceId) => [
surfaceId,
{
...settings.pages[surfaceId],
background: {
...settings.pages[surfaceId].background,
items: settings.pages[surfaceId].background.items.map((item) => ({
...item,
})),
},
},
])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
};
}
export const environmentSurfaceIds = surfaceIds;
@@ -0,0 +1,130 @@
import { useCallback, useEffect, useState } from "react";
import {
decodeEnvironmentSettings,
decodeUploadedEnvironmentMedia,
defaultEnvironmentSettings,
encodeEnvironmentSettings,
type EnvironmentSettings,
type EnvironmentSurfaceId,
type UploadedEnvironmentMedia,
} from "./environmentSettings";
interface EnvironmentSettingsController {
settings: EnvironmentSettings;
state: "loading" | "ready" | "saving" | "error";
error: string | null;
save: (draft: EnvironmentSettings) => Promise<EnvironmentSettings>;
upload: (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
async function responseError(response: Response, fallback: string): Promise<string> {
try {
const body = await response.json() as { detail?: unknown };
if (typeof body.detail === "string" && body.detail.trim()) return body.detail;
} catch {
// A non-JSON reverse-proxy response still gets a stable product message.
}
return fallback;
}
export function useEnvironmentSettings(): EnvironmentSettingsController {
const [settings, setSettings] = useState<EnvironmentSettings>(
defaultEnvironmentSettings,
);
const [state, setState] = useState<EnvironmentSettingsController["state"]>(
"loading",
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
void fetch("/api/v1/environment/settings", {
signal: controller.signal,
headers: { Accept: "application/json" },
}).then(async (response) => {
if (!response.ok) {
throw new Error(await responseError(
response,
"Не удалось загрузить настройки окружения.",
));
}
return decodeEnvironmentSettings(await response.json());
}).then((document) => {
setSettings(document);
setState("ready");
setError(null);
}).catch((reason: unknown) => {
if (controller.signal.aborted) return;
setState("error");
setError(reason instanceof Error
? reason.message
: "Не удалось загрузить настройки окружения.");
});
return () => controller.abort();
}, []);
const save = useCallback(async (draft: EnvironmentSettings) => {
setState("saving");
setError(null);
try {
const response = await fetch("/api/v1/environment/settings", {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(encodeEnvironmentSettings(draft)),
});
if (!response.ok) {
throw new Error(await responseError(
response,
"Не удалось сохранить настройки окружения.",
));
}
const document = decodeEnvironmentSettings(await response.json());
setSettings(document);
setState("ready");
return document;
} catch (reason) {
const message = reason instanceof Error
? reason.message
: "Не удалось сохранить настройки окружения.";
setState("error");
setError(message);
throw new Error(message);
}
}, []);
const upload = useCallback(async (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => {
const response = await fetch(
`/api/v1/environment/media/${surfaceId}/${itemId}`,
{
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": file.type || "application/octet-stream",
"X-NODEDC-File-Name": encodeURIComponent(file.name),
},
body: file,
},
);
if (!response.ok) {
throw new Error(await responseError(
response,
"Не удалось загрузить фон окружения.",
));
}
return decodeUploadedEnvironmentMedia(await response.json());
}, []);
return { settings, state, error, save, upload };
}
@@ -0,0 +1,526 @@
import {
AdvancedLaboratoryContractError,
fetchOne,
parseE31,
parseE32,
parseE33,
parseE37,
parseE38,
parseE39,
type AdvancedLaboratoryResults,
type LaboratoryFetch,
} from "./advancedResults";
import { fetchE34TemporalLayerResult } from "./e34TemporalLayer";
import { fetchE35DegradationRecoveryResult } from "./e35DegradationRecovery";
import { fetchE40ProductGateResult } from "./e40ProductGate";
import { fetchL3PointPillarsVisualAudit } from "./l3PointPillarsVisualAudit";
import { fetchL31PointPillarsRavnoves } from "./l31PointPillarsRavnoves";
import { fetchL32PointPillarsCameraReview } from "./l32PointPillarsCameraReview";
import { fetchL33CameraFirstDetectorReview } from "./l33CameraFirstDetectorReview";
import { fetchL34RightYoloxTruthIsland } from "./l34RightYoloxTruthIsland";
import { fetchL34AAssistedYoloxErrorAudit } from "./l34aAssistedYoloxErrorAudit";
import { fetchL34BNestedBoxConsolidation } from "./l34bNestedBoxConsolidation";
import { fetchL34CTileSeamStitch } from "./l34cTileSeamStitch";
import { fetchL34DCumulativePostprocessing } from "./l34dCumulativePostprocessing";
import { fetchL34ESelfReviewDiagnostic } from "./l34eSelfReviewDiagnostic";
import { fetchL34FFrozenResult } from "./l34fAdjudication";
import { fetchE46BlindReviewResult } from "./e46BlindReview";
import { fetchE46AAiEngineeringPreannotation } from "./e46aAiEngineeringPreannotation";
import { fetchE46BTemporalMotion } from "./e46bTemporalMotion";
import { fetchE46CFullReplayWorldTracks } from "./e46cFullReplayWorldTracks";
import { fetchE46DTemporalFailureAudit } from "./e46dTemporalFailureAudit";
import { fetchE46EReadyStack } from "./e46eReadyStack";
import { fetchE46FDashCamBakeoff } from "./e46fDashCamBakeoff";
import { fetchE46GRectifiedDetectorBakeoff } from "./e46gRectifiedDetectorBakeoff";
import { fetchE46HFullRectifiedFrontReplay } from "./e46hFullRectifiedFrontReplay";
import { fetchE46IGroundingDinoFullReplay } from "./e46iGroundingDinoFullReplay";
import { fetchE46JRawFisheyeRealtime } from "./e46jRawFisheyeRealtime";
import { fetchE47SemanticSlamResult } from "./e47SemanticSlam";
import { fetchM4ThreatReplayResult } from "./m4ReplayThreat";
import { fetchM47ReferenceGraphLab } from "./m47ReferenceGraph";
import {
fetchM48LifecycleResult,
} from "./m48ObjectCentricQuality";
import { fetchM48SmallStaticRegression } from "./m48SmallStaticRegression";
import { fetchM48StaticOccupancyQualification } from "./m48StaticOccupancyQualification";
import { fetchM48R3StaticOccupancyShadow } from "./m48r3StaticOccupancyShadow";
import { fetchM48SFixedClassDetectorResult } from "./m48sFixedClassDetector";
import { fetchM48TRiskQualityResult } from "./m48tRiskQuality";
import { fetchM49TgsFailClosedResult } from "./m49TgsFailClosed";
import { fetchM49TgsFullShadowResult } from "./m49TgsFullShadow";
export type AdvancedLaboratoryWorkId =
| "m48-object-centric-quality"
| "m48-small-static-passage-regression"
| "m48-static-occupancy-qualification"
| "m48r3-static-occupancy-shadow"
| "m48s-fixed-class-detector"
| "m48t-risk-quality-temporal"
| "m49-tgs-fail-closed-evidence"
| "m49-tgs-full-shadow"
| "m47-reference-graph-shadow"
| "m4-replay-threat"
| "l3-pointpillars-visual-audit"
| "l31-pointpillars-ravnoves"
| "l32-pointpillars-camera-review"
| "l33-camera-first-detector-review"
| "e31-source-binding"
| "e32-track-geometry"
| "e33-worker-shadow"
| "e34-temporal-layer"
| "e35-degradation-recovery"
| "e37-ravnoves-acceptance"
| "e38-perception-baseline"
| "e39-perception-refinement"
| "e40-perception-product-gate"
| "e46-detector-truth-island"
| "e46a-ai-engineering-preannotation"
| "e46b-temporal-motion"
| "e46c-full-replay-world-tracks"
| "e46d-temporal-failure-audit"
| "e46e-ready-stack"
| "e46f-dashcam-bakeoff"
| "e46g-rectified-detector-bakeoff"
| "e46h-full-rectified-front-replay"
| "e46i-grounding-dino-full-replay"
| "e46j-raw-fisheye-realtime"
| "e47-semantic-slam-shadow"
| "l34-right-yolox-truth-island-freeze"
| "l34a-assisted-yolox-error-audit"
| "l34b-nested-box-consolidation-shadow"
| "l34c-tile-seam-stitch-shadow"
| "l34d-cumulative-postprocessing-candidate"
| "l34e-self-review-diagnostic"
| "l34f-adjudicated-reference";
export interface AdvancedLaboratoryIndexItem {
workId: AdvancedLaboratoryWorkId;
resultId: string;
createdAtUtc: string;
}
const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
"m48-object-centric-quality",
"m48-small-static-passage-regression",
"m48-static-occupancy-qualification",
"m48r3-static-occupancy-shadow",
"m48s-fixed-class-detector",
"m48t-risk-quality-temporal",
"m49-tgs-fail-closed-evidence",
"m49-tgs-full-shadow",
"m47-reference-graph-shadow",
"m4-replay-threat",
"l3-pointpillars-visual-audit",
"l31-pointpillars-ravnoves",
"l32-pointpillars-camera-review",
"l33-camera-first-detector-review",
"e31-source-binding",
"e32-track-geometry",
"e33-worker-shadow",
"e34-temporal-layer",
"e35-degradation-recovery",
"e37-ravnoves-acceptance",
"e38-perception-baseline",
"e39-perception-refinement",
"e40-perception-product-gate",
"e46-detector-truth-island",
"e46a-ai-engineering-preannotation",
"e46b-temporal-motion",
"e46c-full-replay-world-tracks",
"e46d-temporal-failure-audit",
"e46e-ready-stack",
"e46f-dashcam-bakeoff",
"e46g-rectified-detector-bakeoff",
"e46h-full-rectified-front-replay",
"e46i-grounding-dino-full-replay",
"e46j-raw-fisheye-realtime",
"e47-semantic-slam-shadow",
"l34-right-yolox-truth-island-freeze",
"l34a-assisted-yolox-error-audit",
"l34b-nested-box-consolidation-shadow",
"l34c-tile-seam-stitch-shadow",
"l34d-cumulative-postprocessing-candidate",
"l34e-self-review-diagnostic",
"l34f-adjudicated-reference",
];
const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
"m48-object-centric-quality": "m48-object-quality-(?:pack|result)",
"m48-small-static-passage-regression": "m48-small-static-passage-regression",
"m48-static-occupancy-qualification": "m48-static-occupancy-qualification",
"m48r3-static-occupancy-shadow": "m48r3-static-occupancy-shadow",
"m48s-fixed-class-detector": "m48s-fixed-class-detector-lab",
"m48t-risk-quality-temporal": "(?:m48t-risk-quality-temporal-lab|m48q-native-risk-quality-lab)",
"m49-tgs-fail-closed-evidence": "m49-tgs-fail-closed",
"m49-tgs-full-shadow": "m49-tgs-full-shadow",
"m47-reference-graph-shadow": "m47-reference-graph-lab",
"m4-replay-threat": "m4-threat-replay",
"l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit",
"l31-pointpillars-ravnoves": "l31-pointpillars-ravnoves",
"l32-pointpillars-camera-review": "l32-pointpillars-camera-review",
"l33-camera-first-detector-review": "l33-camera-first-detector-review",
"e31-source-binding": "e31-source-qualification",
"e32-track-geometry": "e32-track-geometry",
"e33-worker-shadow": "e33-worker-shadow",
"e34-temporal-layer": "e34-temporal-occupied",
"e35-degradation-recovery": "e35-degradation-recovery",
"e37-ravnoves-acceptance": "e37-ravnoves-acceptance",
"e38-perception-baseline": "e38-perception-baseline",
"e39-perception-refinement": "e39-perception-refinement",
"e40-perception-product-gate": "e40-perception-product-gate",
"e46-detector-truth-island": "e46-detector-truth-island",
"e46a-ai-engineering-preannotation": "e46a-ai-engineering-preannotation",
"e46b-temporal-motion": "e46b-temporal-motion",
"e46c-full-replay-world-tracks": "e46c-full-replay-world-tracks",
"e46d-temporal-failure-audit": "e46d-temporal-failure-audit",
"e46e-ready-stack": "e46e-ready-stack",
"e46f-dashcam-bakeoff": "e46f-dashcam-bakeoff",
"e46g-rectified-detector-bakeoff": "e46g-rectified-detector-bakeoff",
"e46h-full-rectified-front-replay": "e46h-full-rectified-front-replay",
"e46i-grounding-dino-full-replay": "e46i-grounding-dino-full-replay",
"e46j-raw-fisheye-realtime": "e46j-raw-fisheye-realtime",
"e47-semantic-slam-shadow": "e47-semantic-slam",
"l34-right-yolox-truth-island-freeze": "l34-right-yolox-truth-island-freeze",
"l34a-assisted-yolox-error-audit": "l34a-assisted-yolox-error-audit",
"l34b-nested-box-consolidation-shadow": "l34b-nested-box-consolidation-shadow",
"l34c-tile-seam-stitch-shadow": "l34c-tile-seam-stitch-shadow",
"l34d-cumulative-postprocessing-candidate": "l34d-cumulative-postprocessing-candidate",
"l34e-self-review-diagnostic": "l34e-self-review-diagnostic",
"l34f-adjudicated-reference": "l34f-adjudicated-reference",
};
export function isAdvancedLaboratoryWorkId(
value: string,
): value is AdvancedLaboratoryWorkId {
return WORK_IDS.includes(value as AdvancedLaboratoryWorkId);
}
export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
return {
m47Graph: null,
m48: null,
m48SmallStatic: null,
m48StaticOccupancy: null,
m48r3StaticOccupancy: null,
m48s: null,
m48t: null,
m49Tgs: null,
m49TgsFull: null,
m4Threat: null,
l3: null,
l31: null,
l32: null,
l33: null,
e31: null,
e32: null,
e33: null,
e34: null,
e35: null,
e37: null,
e38: null,
e39: null,
e40: null,
e46: null,
e46a: null,
e46b: null,
e46c: null,
e46d: null,
e46e: null,
e46f: null,
e46g: null,
e46h: null,
e46i: null,
e46j: null,
e47: null,
l34: null,
l34a: null,
l34b: null,
l34c: null,
l34d: null,
l34e: null,
l34f: null,
};
}
function objectValue(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function exactString(value: unknown, expected: string, label: string): string {
if (value !== expected) {
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
}
return expected;
}
function parseIndexItem(value: unknown): AdvancedLaboratoryIndexItem {
const item = objectValue(value, "Индекс LAB.item");
if (
typeof item.work_id !== "string"
|| !isAdvancedLaboratoryWorkId(item.work_id)
) {
throw new AdvancedLaboratoryContractError(
"Индекс LAB.work_id: неизвестная лабораторная работа.",
);
}
const prefix = RESULT_PREFIX[item.work_id];
if (
typeof item.result_id !== "string"
|| !new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(item.result_id)
) {
throw new AdvancedLaboratoryContractError(
"Индекс LAB.result_id: нарушена идентичность результата.",
);
}
if (typeof item.created_at_utc !== "string" || !item.created_at_utc.trim()) {
throw new AdvancedLaboratoryContractError(
"Индекс LAB.created_at_utc: ожидалась строка.",
);
}
exactString(item.access, "read-only", "Индекс LAB.item.access");
return {
workId: item.work_id,
resultId: item.result_id,
createdAtUtc: item.created_at_utc,
};
}
export async function fetchAdvancedLaboratoryIndex({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<readonly AdvancedLaboratoryIndexItem[]> {
const response = await fetcher("/api/v1/laboratory/advanced-index", {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) {
throw new AdvancedLaboratoryContractError(
`Индекс LAB недоступен: HTTP ${response.status}.`,
);
}
const payload = objectValue(await response.json(), "Индекс LAB");
exactString(
payload.schema_version,
"missioncore.laboratory-advanced-index/v1",
"Индекс LAB.schema_version",
);
exactString(payload.access, "read-only", "Индекс LAB.access");
if (!Array.isArray(payload.items) || payload.items.length > WORK_IDS.length) {
throw new AdvancedLaboratoryContractError(
"Индекс LAB.items: нарушен размер каталога.",
);
}
const items = payload.items.map(parseIndexItem);
if (new Set(items.map((item) => item.workId)).size !== items.length) {
throw new AdvancedLaboratoryContractError(
"Индекс LAB.items: лабораторная работа продублирована.",
);
}
return items;
}
export function advancedLaboratoryResultAvailable(
workId: AdvancedLaboratoryWorkId,
results: AdvancedLaboratoryResults,
): boolean {
return workId === "m48-object-centric-quality" ? results.m48 !== null
: workId === "m48-small-static-passage-regression" ? results.m48SmallStatic !== null
: workId === "m48-static-occupancy-qualification" ? results.m48StaticOccupancy !== null
: workId === "m48r3-static-occupancy-shadow" ? results.m48r3StaticOccupancy !== null
: workId === "m48s-fixed-class-detector" ? results.m48s !== null
: workId === "m48t-risk-quality-temporal" ? results.m48t !== null
: workId === "m49-tgs-fail-closed-evidence" ? results.m49Tgs !== null
: workId === "m49-tgs-full-shadow" ? results.m49TgsFull !== null
: workId === "m47-reference-graph-shadow" ? results.m47Graph !== null
: workId === "m4-replay-threat" ? results.m4Threat !== null
: workId === "l3-pointpillars-visual-audit" ? results.l3 !== null
: workId === "l31-pointpillars-ravnoves" ? results.l31 !== null
: workId === "l32-pointpillars-camera-review" ? results.l32 !== null
: workId === "l33-camera-first-detector-review" ? results.l33 !== null
: workId === "e31-source-binding" ? results.e31 !== null
: workId === "e32-track-geometry" ? results.e32 !== null
: workId === "e33-worker-shadow" ? results.e33 !== null
: workId === "e34-temporal-layer" ? results.e34 !== null
: workId === "e35-degradation-recovery" ? results.e35 !== null
: workId === "e37-ravnoves-acceptance" ? results.e37 !== null
: workId === "e38-perception-baseline" ? results.e38 !== null
: workId === "e39-perception-refinement" ? results.e39 !== null
: workId === "e40-perception-product-gate" ? results.e40 !== null
: workId === "e46-detector-truth-island" ? results.e46 !== null
: workId === "e46a-ai-engineering-preannotation" ? results.e46a !== null
: workId === "e46b-temporal-motion" ? results.e46b !== null
: workId === "e46c-full-replay-world-tracks" ? results.e46c !== null
: workId === "e46d-temporal-failure-audit" ? results.e46d !== null
: workId === "e46e-ready-stack" ? results.e46e !== null
: workId === "e46f-dashcam-bakeoff" ? results.e46f !== null
: workId === "e46g-rectified-detector-bakeoff" ? results.e46g !== null
: workId === "e46h-full-rectified-front-replay" ? results.e46h !== null
: workId === "e46i-grounding-dino-full-replay" ? results.e46i !== null
: workId === "e46j-raw-fisheye-realtime" ? results.e46j !== null
: workId === "e47-semantic-slam-shadow" ? results.e47 !== null
: workId === "l34-right-yolox-truth-island-freeze" ? results.l34 !== null
: workId === "l34a-assisted-yolox-error-audit" ? results.l34a !== null
: workId === "l34b-nested-box-consolidation-shadow" ? results.l34b !== null
: workId === "l34c-tile-seam-stitch-shadow" ? results.l34c !== null
: workId === "l34d-cumulative-postprocessing-candidate" ? results.l34d !== null
: workId === "l34e-self-review-diagnostic" ? results.l34e !== null
: results.l34f !== null;
}
export async function fetchAdvancedLaboratoryResult(
workId: AdvancedLaboratoryWorkId,
{
fetcher = fetch,
signal,
resultId,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
resultId?: string;
} = {},
): Promise<AdvancedLaboratoryResults> {
const results = emptyAdvancedLaboratoryResults();
if (workId === "m48-object-centric-quality") {
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8 lifecycle evidence identity не выбрана.");
results.m48 = await fetchM48LifecycleResult(resultId, { fetcher, signal });
} else if (workId === "m48-small-static-passage-regression") {
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R1 regression identity не выбрана.");
results.m48SmallStatic = await fetchM48SmallStaticRegression(resultId, { fetcher, signal });
} else if (workId === "m48-static-occupancy-qualification") {
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R2 qualification identity не выбрана.");
results.m48StaticOccupancy = await fetchM48StaticOccupancyQualification(resultId, { fetcher, signal });
} else if (workId === "m48r3-static-occupancy-shadow") {
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R3 Worker shadow identity не выбрана.");
results.m48r3StaticOccupancy = await fetchM48R3StaticOccupancyShadow(resultId, { fetcher, signal });
} else if (workId === "m48s-fixed-class-detector") {
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8S LAB identity не выбрана.");
results.m48s = await fetchM48SFixedClassDetectorResult(resultId, { fetcher, signal });
} else if (workId === "m48t-risk-quality-temporal") {
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8T LAB identity не выбрана.");
results.m48t = await fetchM48TRiskQualityResult(resultId, { fetcher, signal });
} else if (workId === "m49-tgs-fail-closed-evidence") {
if (!resultId) throw new AdvancedLaboratoryContractError("M4.9 TGS LAB identity не выбрана.");
results.m49Tgs = await fetchM49TgsFailClosedResult(resultId, { fetcher, signal });
} else if (workId === "m49-tgs-full-shadow") {
if (!resultId) throw new AdvancedLaboratoryContractError("M4.9 full TGS shadow identity не выбрана.");
results.m49TgsFull = await fetchM49TgsFullShadowResult(resultId, { fetcher, signal });
} else if (workId === "m47-reference-graph-shadow") {
if (!resultId) {
throw new AdvancedLaboratoryContractError("M4.7 LAB identity не выбрана.");
}
results.m47Graph = await fetchM47ReferenceGraphLab({
resultId,
fetcher,
signal,
});
} else if (workId === "m4-replay-threat") {
results.m4Threat = await fetchM4ThreatReplayResult({ fetcher, signal });
} else if (workId === "l3-pointpillars-visual-audit") {
results.l3 = await fetchL3PointPillarsVisualAudit({ fetcher, signal });
} else if (workId === "l31-pointpillars-ravnoves") {
results.l31 = await fetchL31PointPillarsRavnoves({ fetcher, signal });
} else if (workId === "l32-pointpillars-camera-review") {
results.l32 = await fetchL32PointPillarsCameraReview({ fetcher, signal });
} else if (workId === "l33-camera-first-detector-review") {
results.l33 = await fetchL33CameraFirstDetectorReview({ fetcher, signal });
} else if (workId === "e31-source-binding") {
results.e31 = await fetchOne(
"/api/v1/laboratory/e31/results?limit=1",
parseE31,
fetcher,
signal,
);
} else if (workId === "e32-track-geometry") {
results.e32 = await fetchOne(
"/api/v1/laboratory/e32/results?limit=1",
parseE32,
fetcher,
signal,
);
} else if (workId === "e33-worker-shadow") {
results.e33 = await fetchOne(
"/api/v1/laboratory/e33/results?limit=1",
parseE33,
fetcher,
signal,
);
} else if (workId === "e34-temporal-layer") {
results.e34 = await fetchE34TemporalLayerResult({ fetcher, signal });
} else if (workId === "e35-degradation-recovery") {
results.e35 = await fetchE35DegradationRecoveryResult({ fetcher, signal });
} else if (workId === "e37-ravnoves-acceptance") {
results.e37 = await fetchOne(
"/api/v1/laboratory/e37/results?limit=1",
parseE37,
fetcher,
signal,
);
} else if (workId === "e38-perception-baseline") {
results.e38 = await fetchOne(
"/api/v1/laboratory/e38/results?limit=1",
parseE38,
fetcher,
signal,
);
} else if (workId === "e39-perception-refinement") {
results.e39 = await fetchOne(
"/api/v1/laboratory/e39/results?limit=1",
parseE39,
fetcher,
signal,
);
} else if (workId === "e40-perception-product-gate") {
results.e40 = await fetchE40ProductGateResult({ fetcher, signal });
} else if (workId === "e46-detector-truth-island") {
results.e46 = await fetchE46BlindReviewResult({ fetcher, signal });
} else if (workId === "e46a-ai-engineering-preannotation") {
results.e46a = await fetchE46AAiEngineeringPreannotation({ fetcher, signal });
} else if (workId === "e46b-temporal-motion") {
results.e46b = await fetchE46BTemporalMotion({ fetcher, signal });
} else if (workId === "e46c-full-replay-world-tracks") {
results.e46c = await fetchE46CFullReplayWorldTracks({ fetcher, signal });
} else if (workId === "e46d-temporal-failure-audit") {
results.e46d = await fetchE46DTemporalFailureAudit({ fetcher, signal });
} else if (workId === "e46e-ready-stack") {
results.e46e = await fetchE46EReadyStack({ fetcher, signal });
} else if (workId === "e46f-dashcam-bakeoff") {
results.e46f = await fetchE46FDashCamBakeoff({ fetcher, signal });
} else if (workId === "e46g-rectified-detector-bakeoff") {
results.e46g = await fetchE46GRectifiedDetectorBakeoff({ fetcher, signal });
} else if (workId === "e46h-full-rectified-front-replay") {
results.e46h = await fetchE46HFullRectifiedFrontReplay({ fetcher, signal });
} else if (workId === "e46i-grounding-dino-full-replay") {
results.e46i = await fetchE46IGroundingDinoFullReplay({ fetcher, signal });
} else if (workId === "e46j-raw-fisheye-realtime") {
results.e46j = await fetchE46JRawFisheyeRealtime({ fetcher, signal });
} else if (workId === "e47-semantic-slam-shadow") {
results.e47 = await fetchE47SemanticSlamResult({ fetcher, signal });
} else if (workId === "l34-right-yolox-truth-island-freeze") {
results.l34 = await fetchL34RightYoloxTruthIsland({ fetcher, signal });
} else if (workId === "l34a-assisted-yolox-error-audit") {
results.l34a = await fetchL34AAssistedYoloxErrorAudit({ fetcher, signal });
} else if (workId === "l34b-nested-box-consolidation-shadow") {
results.l34b = await fetchL34BNestedBoxConsolidation({ fetcher, signal });
} else if (workId === "l34c-tile-seam-stitch-shadow") {
results.l34c = await fetchL34CTileSeamStitch({ fetcher, signal });
} else if (workId === "l34d-cumulative-postprocessing-candidate") {
results.l34d = await fetchL34DCumulativePostprocessing({ fetcher, signal });
} else if (workId === "l34e-self-review-diagnostic") {
results.l34e = await fetchL34ESelfReviewDiagnostic({ fetcher, signal });
} else {
results.l34f = await fetchL34FFrozenResult({ fetcher, signal });
}
if (!advancedLaboratoryResultAvailable(workId, results)) {
throw new AdvancedLaboratoryContractError(
"Выбранная лабораторная работа не прошла серверную проверку.",
);
}
return results;
}
@@ -0,0 +1,89 @@
import type {
E31LaboratoryResult,
E32LaboratoryResult,
E33LaboratoryResult,
E37AcceptanceContractResult,
E38PerceptionBaselineResult,
E39PerceptionRefinementResult,
} from "./advancedResults";
import type { E34TemporalLayerResult } from "./e34TemporalLayer";
import type { E35DegradationRecoveryResult } from "./e35DegradationRecovery";
import type { E40PerceptionProductGateResult } from "./e40ProductGate";
import type { L3PointPillarsVisualAuditResult } from "./l3PointPillarsVisualAudit";
import type { L31PointPillarsRavnovesResult } from "./l31PointPillarsRavnoves";
import type { L32PointPillarsCameraReviewResult } from "./l32PointPillarsCameraReview";
import type { L33CameraFirstDetectorReviewResult } from "./l33CameraFirstDetectorReview";
import type { L34RightYoloxTruthIslandResult } from "./l34RightYoloxTruthIsland";
import type { L34AAssistedYoloxErrorAuditResult } from "./l34aAssistedYoloxErrorAudit";
import type { L34BResult } from "./l34bNestedBoxConsolidation";
import type { L34CResult } from "./l34cTileSeamStitch";
import type { L34DResult } from "./l34dCumulativePostprocessing";
import type { L34EResult } from "./l34eSelfReviewDiagnostic";
import type { L34FFrozenResult } from "./l34fAdjudication";
import type { E46BlindReviewResult } from "./e46BlindReview";
import type { E46AAiEngineeringPreannotationResult } from "./e46aAiEngineeringPreannotation";
import type { E46BTemporalMotionResult } from "./e46bTemporalMotion";
import type { E46CFullReplayWorldTracksResult } from "./e46cFullReplayWorldTracks";
import type { E46DTemporalFailureAuditResult } from "./e46dTemporalFailureAudit";
import type { E46EReadyStackResult } from "./e46eReadyStack";
import type { E46FDashCamBakeoffResult } from "./e46fDashCamBakeoff";
import type { E46GRectifiedDetectorBakeoffResult } from "./e46gRectifiedDetectorBakeoff";
import type { E46HFullRectifiedFrontReplayResult } from "./e46hFullRectifiedFrontReplay";
import type { E46IGroundingDinoFullReplayResult } from "./e46iGroundingDinoFullReplay";
import type { E46JRawFisheyeRealtimeResult } from "./e46jRawFisheyeRealtime";
import type { E47SemanticSlamResult } from "./e47SemanticSlam";
import type { M4ThreatReplayResult } from "./m4ReplayThreat";
import type { M47ReferenceGraphLabResult } from "./m47ReferenceGraph";
import type { M48AdvancedResult } from "./m48ObjectCentricQuality";
import type { M48SmallStaticRegressionResult } from "./m48SmallStaticRegression";
import type { M48StaticOccupancyQualificationResult } from "./m48StaticOccupancyQualification";
import type { M48R3StaticOccupancyShadowResult } from "./m48r3StaticOccupancyShadow";
import type { M48SFixedClassDetectorResult } from "./m48sFixedClassDetector";
import type { M48TRiskQualityResult } from "./m48tRiskQuality";
import type { M49TgsFailClosedResult } from "./m49TgsFailClosed";
import type { M49TgsFullShadowResult } from "./m49TgsFullShadow";
export interface AdvancedLaboratoryResults {
m47Graph: M47ReferenceGraphLabResult | null;
m48: M48AdvancedResult | null;
m48SmallStatic: M48SmallStaticRegressionResult | null;
m48StaticOccupancy: M48StaticOccupancyQualificationResult | null;
m48r3StaticOccupancy: M48R3StaticOccupancyShadowResult | null;
m48s: M48SFixedClassDetectorResult | null;
m48t: M48TRiskQualityResult | null;
m49Tgs: M49TgsFailClosedResult | null;
m49TgsFull: M49TgsFullShadowResult | null;
m4Threat: M4ThreatReplayResult | null;
l3: L3PointPillarsVisualAuditResult | null;
l31: L31PointPillarsRavnovesResult | null;
l32: L32PointPillarsCameraReviewResult | null;
l33: L33CameraFirstDetectorReviewResult | null;
e31: E31LaboratoryResult | null;
e32: E32LaboratoryResult | null;
e33: E33LaboratoryResult | null;
e34: E34TemporalLayerResult | null;
e35: E35DegradationRecoveryResult | null;
e37: E37AcceptanceContractResult | null;
e38: E38PerceptionBaselineResult | null;
e39: E39PerceptionRefinementResult | null;
e40: E40PerceptionProductGateResult | null;
e46: E46BlindReviewResult | null;
e46a: E46AAiEngineeringPreannotationResult | null;
e46b: E46BTemporalMotionResult | null;
e46c: E46CFullReplayWorldTracksResult | null;
e46d: E46DTemporalFailureAuditResult | null;
e46e: E46EReadyStackResult | null;
e46f: E46FDashCamBakeoffResult | null;
e46g: E46GRectifiedDetectorBakeoffResult | null;
e46h: E46HFullRectifiedFrontReplayResult | null;
e46i: E46IGroundingDinoFullReplayResult | null;
e46j: E46JRawFisheyeRealtimeResult | null;
e47: E47SemanticSlamResult | null;
l34: L34RightYoloxTruthIslandResult | null;
l34a: L34AAssistedYoloxErrorAuditResult | null;
l34b: L34BResult | null;
l34c: L34CResult | null;
l34d: L34DResult | null;
l34e: L34EResult | null;
l34f: L34FFrozenResult | null;
}
@@ -0,0 +1,999 @@
import { fetchE34TemporalLayerResult } from "./e34TemporalLayer";
import { fetchE35DegradationRecoveryResult } from "./e35DegradationRecovery";
import { fetchE40ProductGateResult } from "./e40ProductGate";
import { settledCatalogValue } from "./catalogTransport";
import type { AdvancedLaboratoryResults } from "./advancedLaboratoryResults";
export type { AdvancedLaboratoryResults } from "./advancedLaboratoryResults";
export interface E31LaboratoryResult {
resultId: string;
createdAtUtc: string | null;
sourceSessionId: string;
status: "accepted-diagnostic-source-profile";
eligibleForE32: true;
profileId: string;
producerSha256: string;
metrics: {
frameCount: number;
availableBindingCount: number;
availableFraction: number;
lidarCameraP95Ms: number;
posePointP95Ms: number;
selectedOffsetMs: number;
correspondenceCount: number;
supportedFraction: number;
semanticSelfSampleCount: number;
semanticSelfCollateralCount: number;
exactGeometryCorrectionCount: number;
geometryPointMaskStatus: string;
};
limitations: readonly string[];
access: "read-only";
}
export interface E32LaboratoryResult {
resultId: string;
createdAtUtc: string | null;
sourceSessionId: string;
status: "accepted-diagnostic-track-geometry-replay";
profileId: string;
producerSha256: string;
e31ResultId: string;
metrics: {
framesTotal: number;
framesSourceAvailable: number;
semanticPublished: number;
semanticMasked: number;
geometryPublished: number;
observationsArbitrated: number;
overlappingClaimsRemoved: number;
qualifiedPointsPublished: number;
qualifiedPointsWithheld: number;
frameProcessingP95Ms: number;
buildElapsedMs: number;
};
access: "read-only";
}
export interface E33LaboratoryResult {
resultId: string;
createdAtUtc: string | null;
sourceSessionId: string;
status: "accepted-recorded-source-paced-shadow";
e32ResultId: string;
pipelineId: string;
mode: string;
worker: {
node: string;
containerImage: string;
python: string;
numpy: string;
};
metrics: {
sourceFrames: number;
sourceAvailableFrames: number;
sourceUnavailableFrames: number;
deliveredFrames: number;
inputSuperseded: number;
resultSuperseded: number;
effectiveDeliveryFps: number;
sourceSpanSeconds: number;
deadlineMissFraction: number;
processingP95Ms: number;
releaseLagP95Ms: number;
resultAgeP95Ms: number;
resultAgeMaxMs: number;
resultDeadlineMs: number;
processRssP95Mib: number;
processRssMaxMib: number;
processRssLimitMib: number;
gpuUtilizationP95Percent: number;
gpuVisible: boolean;
workQueueCapacity: number;
resultQueueCapacity: number;
wallToIdealRatio: number;
};
access: "read-only";
}
export interface E37AcceptanceContractResult {
resultId: string;
createdAtUtc: string | null;
sourceSessionId: string;
sourceDisplayName: string;
status: "accepted-r0-source-scoped-contract";
profileId: string;
workerNode: string;
metrics: {
reviewedItems: number;
developmentItems: number;
validationItems: number;
engineeringItems: number;
humanExceptionItems: number;
terminalOutcomes: number;
accountingFraction: number;
falseFreeClaims: number;
};
dimensionDistributions: {
presence: Readonly<Record<string, number>>;
geometryAssociation: Readonly<Record<string, number>>;
freshness: Readonly<Record<string, number>>;
};
severityDistribution: Readonly<Record<string, number>>;
labelProvenance: {
engineeringItems: number;
humanExceptionItems: number;
independentGroundTruth: false;
};
split: {
strategy: string;
validationFraction: number;
};
targets: {
presenceTarget: number;
geometryAssociationTarget: number;
freshnessTarget: number;
};
qualityTargetEvaluated: false;
limitations: readonly string[];
access: "read-only";
}
export interface E38DimensionMetric {
correct: number;
incorrect: number;
total: number;
accuracy: number;
target: number;
passed: boolean;
byStratum: Readonly<Record<string, {
correct: number;
incorrect: number;
total: number;
accuracy: number;
}>>;
confusion: readonly {
reference: string;
prediction: string;
count: number;
}[];
}
export interface E38PerceptionBaselineResult {
resultId: string;
createdAtUtc: string | null;
sourceSessionId: string;
sourceDisplayName: string;
status: "measured-r1-source-scoped-baseline";
profileId: string;
workerNode: string;
qualityGatePassed: boolean;
metrics: {
developmentItems: number;
validationItems: number;
terminalOutcomes: number;
accountingFraction: number;
falseFreeClaims: number;
highSeverityFailures: number;
dimensions: {
presence: E38DimensionMetric;
geometryAssociation: E38DimensionMetric;
freshness: E38DimensionMetric;
};
};
blockingChecks: readonly string[];
limitations: readonly string[];
access: "read-only";
}
export interface DevelopmentDimensionMetric {
correct: number;
incorrect: number;
total: number;
accuracy: number;
target: number;
passed: boolean;
}
export type E39DevelopmentDimensionMetric = DevelopmentDimensionMetric;
export interface E39PerceptionRefinementResult {
resultId: string;
createdAtUtc: string | null;
sourceSessionId: string;
sourceDisplayName: string;
status: "measured-r1-source-scoped-refinement";
profileId: string;
workerNode: string;
qualityGatePassed: boolean;
developmentCrossValidation: {
strategy: string;
seed: string;
folds: number;
items: number;
validationLabelsUsed: false;
passed: boolean;
dimensions: {
presence: DevelopmentDimensionMetric;
geometryAssociation: DevelopmentDimensionMetric;
freshness: DevelopmentDimensionMetric;
};
};
metrics: E38PerceptionBaselineResult["metrics"];
blockingChecks: readonly string[];
method: {
summary: string;
selection: string;
dimensionProjection: string;
};
limitations: readonly string[];
access: "read-only";
}
export class AdvancedLaboratoryContractError extends Error {
constructor(message: string) {
super(message);
this.name = "AdvancedLaboratoryContractError";
}
}
export type LaboratoryFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function stringValue(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`);
}
return value;
}
function optionalString(value: unknown, label: string): string | null {
return value === null ? null : stringValue(value, label);
}
function numberValue(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new AdvancedLaboratoryContractError(`${label}: ожидалось число.`);
}
return value;
}
function integerValue(value: unknown, label: string): number {
const parsed = numberValue(value, label);
if (!Number.isSafeInteger(parsed)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое число.`);
}
return parsed;
}
function booleanValue(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new AdvancedLaboratoryContractError(`${label}: ожидался boolean.`);
}
return value;
}
function trueValue(value: unknown, label: string): true {
if (value !== true) {
throw new AdvancedLaboratoryContractError(`${label}: ожидалось true.`);
}
return true;
}
function falseValue(value: unknown, label: string): false {
if (value !== false) {
throw new AdvancedLaboratoryContractError(`${label}: ожидалось false.`);
}
return false;
}
function finiteNumber(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидалось число.`);
}
return value;
}
function exactString<T extends string>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) {
throw new AdvancedLaboratoryContractError(`${label}: неверное значение.`);
}
return expected;
}
function strings(value: unknown, label: string): readonly string[] {
if (!Array.isArray(value)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`);
}
return value.map((item, index) => stringValue(item, `${label}[${index}]`));
}
function numberRecord(
value: unknown,
label: string,
): Readonly<Record<string, number>> {
const source = record(value, label);
return Object.fromEntries(
Object.entries(source).map(([key, item]) => [
key,
integerValue(item, `${label}.${key}`),
]),
);
}
function contentId(value: unknown, prefix: string, label: string): string {
const parsed = stringValue(value, label);
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
throw new AdvancedLaboratoryContractError(`${label}: неверный content id.`);
}
return parsed;
}
function sha256(value: unknown, label: string): string {
const parsed = stringValue(value, label);
if (!/^[a-f0-9]{64}$/.test(parsed)) {
throw new AdvancedLaboratoryContractError(`${label}: неверный SHA-256.`);
}
return parsed;
}
function diagnosticAuthority(value: unknown, label: string): void {
const authority = record(value, label);
if (
authority.commands_enabled !== false
|| authority.navigation_or_safety_accepted !== false
) {
throw new AdvancedLaboratoryContractError(`${label}: запрещённые полномочия.`);
}
}
function parseCatalog<T>(
payload: unknown,
parseItem: (value: unknown) => T,
): T | null {
const value = record(payload, "Каталог LAB");
exactString(
value.schema_version,
"missioncore.laboratory-advanced-catalog/v1",
"Каталог LAB.schema_version",
);
booleanValue(value.configured, "Каталог LAB.configured");
integerValue(value.candidate_total, "Каталог LAB.candidate_total");
integerValue(value.invalid_total, "Каталог LAB.invalid_total");
exactString(value.access, "read-only", "Каталог LAB.access");
if (!Array.isArray(value.items)) {
throw new AdvancedLaboratoryContractError("Каталог LAB.items: ожидался массив.");
}
if (value.items.length > 1) {
throw new AdvancedLaboratoryContractError("Каталог LAB.items: нарушен limit=1.");
}
return value.items.length ? parseItem(value.items[0]) : null;
}
export function parseE31(value: unknown): E31LaboratoryResult {
const item = record(value, "E31");
const metrics = record(item.metrics, "E31.metrics");
diagnosticAuthority(item.authority, "E31.authority");
return {
resultId: contentId(item.result_id, "e31-source-qualification", "E31.result_id"),
createdAtUtc: optionalString(item.created_at_utc, "E31.created_at_utc"),
sourceSessionId: stringValue(item.source_session_id, "E31.source_session_id"),
status: exactString(
item.status,
"accepted-diagnostic-source-profile",
"E31.status",
),
eligibleForE32: trueValue(item.eligible_for_e32, "E31.eligible_for_e32"),
profileId: stringValue(item.profile_id, "E31.profile_id"),
producerSha256: sha256(item.producer_sha256, "E31.producer_sha256"),
metrics: {
frameCount: integerValue(metrics.frame_count, "E31.metrics.frame_count"),
availableBindingCount: integerValue(metrics.available_binding_count, "E31.metrics.available_binding_count"),
availableFraction: numberValue(metrics.available_fraction, "E31.metrics.available_fraction"),
lidarCameraP95Ms: numberValue(metrics.lidar_camera_p95_ms, "E31.metrics.lidar_camera_p95_ms"),
posePointP95Ms: numberValue(metrics.pose_point_p95_ms, "E31.metrics.pose_point_p95_ms"),
selectedOffsetMs: finiteNumber(metrics.selected_offset_ms, "E31.metrics.selected_offset_ms"),
correspondenceCount: integerValue(metrics.correspondence_count, "E31.metrics.correspondence_count"),
supportedFraction: numberValue(metrics.supported_fraction, "E31.metrics.supported_fraction"),
semanticSelfSampleCount: integerValue(metrics.semantic_self_sample_count, "E31.metrics.semantic_self_sample_count"),
semanticSelfCollateralCount: integerValue(metrics.semantic_self_collateral_count, "E31.metrics.semantic_self_collateral_count"),
exactGeometryCorrectionCount: integerValue(metrics.exact_geometry_correction_count, "E31.metrics.exact_geometry_correction_count"),
geometryPointMaskStatus: stringValue(metrics.geometry_point_mask_status, "E31.metrics.geometry_point_mask_status"),
},
limitations: strings(item.limitations, "E31.limitations"),
access: exactString(item.access, "read-only", "E31.access"),
};
}
export function parseE32(value: unknown): E32LaboratoryResult {
const item = record(value, "E32");
const metrics = record(item.metrics, "E32.metrics");
diagnosticAuthority(item.authority, "E32.authority");
return {
resultId: contentId(item.result_id, "e32-track-geometry", "E32.result_id"),
createdAtUtc: optionalString(item.created_at_utc, "E32.created_at_utc"),
sourceSessionId: stringValue(item.source_session_id, "E32.source_session_id"),
status: exactString(
item.status,
"accepted-diagnostic-track-geometry-replay",
"E32.status",
),
profileId: stringValue(item.profile_id, "E32.profile_id"),
producerSha256: sha256(item.producer_sha256, "E32.producer_sha256"),
e31ResultId: contentId(item.e31_result_id, "e31-source-qualification", "E32.e31_result_id"),
metrics: {
framesTotal: integerValue(metrics.frames_total, "E32.metrics.frames_total"),
framesSourceAvailable: integerValue(metrics.frames_source_available, "E32.metrics.frames_source_available"),
semanticPublished: integerValue(metrics.semantic_published, "E32.metrics.semantic_published"),
semanticMasked: integerValue(metrics.semantic_masked, "E32.metrics.semantic_masked"),
geometryPublished: integerValue(metrics.geometry_published, "E32.metrics.geometry_published"),
observationsArbitrated: integerValue(metrics.observations_arbitrated, "E32.metrics.observations_arbitrated"),
overlappingClaimsRemoved: integerValue(metrics.overlapping_claims_removed, "E32.metrics.overlapping_claims_removed"),
qualifiedPointsPublished: integerValue(metrics.qualified_points_published, "E32.metrics.qualified_points_published"),
qualifiedPointsWithheld: integerValue(metrics.qualified_points_withheld, "E32.metrics.qualified_points_withheld"),
frameProcessingP95Ms: numberValue(metrics.frame_processing_p95_ms, "E32.metrics.frame_processing_p95_ms"),
buildElapsedMs: numberValue(metrics.build_elapsed_ms, "E32.metrics.build_elapsed_ms"),
},
access: exactString(item.access, "read-only", "E32.access"),
};
}
export function parseE33(value: unknown): E33LaboratoryResult {
const item = record(value, "E33");
const worker = record(item.worker, "E33.worker");
const metrics = record(item.metrics, "E33.metrics");
diagnosticAuthority(item.authority, "E33.authority");
return {
resultId: contentId(item.result_id, "e33-worker-shadow", "E33.result_id"),
createdAtUtc: optionalString(item.created_at_utc, "E33.created_at_utc"),
sourceSessionId: stringValue(item.source_session_id, "E33.source_session_id"),
status: exactString(
item.status,
"accepted-recorded-source-paced-shadow",
"E33.status",
),
e32ResultId: contentId(item.e32_result_id, "e32-track-geometry", "E33.e32_result_id"),
pipelineId: stringValue(item.pipeline_id, "E33.pipeline_id"),
mode: stringValue(item.mode, "E33.mode"),
worker: {
node: stringValue(worker.node, "E33.worker.node"),
containerImage: stringValue(worker.container_image, "E33.worker.container_image"),
python: stringValue(worker.python, "E33.worker.python"),
numpy: stringValue(worker.numpy, "E33.worker.numpy"),
},
metrics: {
sourceFrames: integerValue(metrics.source_frames, "E33.metrics.source_frames"),
sourceAvailableFrames: integerValue(metrics.source_available_frames, "E33.metrics.source_available_frames"),
sourceUnavailableFrames: integerValue(metrics.source_unavailable_frames, "E33.metrics.source_unavailable_frames"),
deliveredFrames: integerValue(metrics.delivered_frames, "E33.metrics.delivered_frames"),
inputSuperseded: integerValue(metrics.input_superseded, "E33.metrics.input_superseded"),
resultSuperseded: integerValue(metrics.result_superseded, "E33.metrics.result_superseded"),
effectiveDeliveryFps: numberValue(metrics.effective_delivery_fps, "E33.metrics.effective_delivery_fps"),
sourceSpanSeconds: numberValue(metrics.source_span_seconds, "E33.metrics.source_span_seconds"),
deadlineMissFraction: numberValue(metrics.deadline_miss_fraction, "E33.metrics.deadline_miss_fraction"),
processingP95Ms: numberValue(metrics.processing_p95_ms, "E33.metrics.processing_p95_ms"),
releaseLagP95Ms: numberValue(metrics.release_lag_p95_ms, "E33.metrics.release_lag_p95_ms"),
resultAgeP95Ms: numberValue(metrics.result_age_p95_ms, "E33.metrics.result_age_p95_ms"),
resultAgeMaxMs: numberValue(metrics.result_age_max_ms, "E33.metrics.result_age_max_ms"),
resultDeadlineMs: numberValue(metrics.result_deadline_ms, "E33.metrics.result_deadline_ms"),
processRssP95Mib: numberValue(metrics.process_rss_p95_mib, "E33.metrics.process_rss_p95_mib"),
processRssMaxMib: numberValue(metrics.process_rss_max_mib, "E33.metrics.process_rss_max_mib"),
processRssLimitMib: numberValue(metrics.process_rss_limit_mib, "E33.metrics.process_rss_limit_mib"),
gpuUtilizationP95Percent: numberValue(metrics.gpu_utilization_p95_percent, "E33.metrics.gpu_utilization_p95_percent"),
gpuVisible: booleanValue(metrics.gpu_visible, "E33.metrics.gpu_visible"),
workQueueCapacity: integerValue(metrics.work_queue_capacity, "E33.metrics.work_queue_capacity"),
resultQueueCapacity: integerValue(metrics.result_queue_capacity, "E33.metrics.result_queue_capacity"),
wallToIdealRatio: numberValue(metrics.wall_to_ideal_ratio, "E33.metrics.wall_to_ideal_ratio"),
},
access: exactString(item.access, "read-only", "E33.access"),
};
}
export function parseE37(value: unknown): E37AcceptanceContractResult {
const item = record(value, "E37");
const metrics = record(item.metrics, "E37.metrics");
const distributions = record(
item.dimension_distributions,
"E37.dimension_distributions",
);
const provenance = record(item.label_provenance, "E37.label_provenance");
const split = record(item.split, "E37.split");
const targets = record(item.targets, "E37.targets");
diagnosticAuthority(item.authority, "E37.authority");
if (item.quality_target_evaluated !== false) {
throw new AdvancedLaboratoryContractError(
"E37.quality_target_evaluated: R0 не должен объявлять метрику проверенной.",
);
}
if (provenance.independent_ground_truth !== false) {
throw new AdvancedLaboratoryContractError(
"E37.label_provenance: R0 не является независимой разметкой.",
);
}
return {
resultId: contentId(
item.result_id,
"e37-ravnoves-acceptance",
"E37.result_id",
),
createdAtUtc: optionalString(item.created_at_utc, "E37.created_at_utc"),
sourceSessionId: stringValue(
item.source_session_id,
"E37.source_session_id",
),
sourceDisplayName: stringValue(
item.source_display_name,
"E37.source_display_name",
),
status: exactString(
item.status,
"accepted-r0-source-scoped-contract",
"E37.status",
),
profileId: stringValue(item.profile_id, "E37.profile_id"),
workerNode: stringValue(item.worker_node, "E37.worker_node"),
metrics: {
reviewedItems: integerValue(
metrics.reviewed_items,
"E37.metrics.reviewed_items",
),
developmentItems: integerValue(
metrics.development_items,
"E37.metrics.development_items",
),
validationItems: integerValue(
metrics.validation_items,
"E37.metrics.validation_items",
),
engineeringItems: integerValue(
metrics.engineering_items,
"E37.metrics.engineering_items",
),
humanExceptionItems: integerValue(
metrics.human_exception_items,
"E37.metrics.human_exception_items",
),
terminalOutcomes: integerValue(
metrics.terminal_outcomes,
"E37.metrics.terminal_outcomes",
),
accountingFraction: numberValue(
metrics.accounting_fraction,
"E37.metrics.accounting_fraction",
),
falseFreeClaims: integerValue(
metrics.false_free_claims,
"E37.metrics.false_free_claims",
),
},
dimensionDistributions: {
presence: numberRecord(
distributions.presence,
"E37.dimension_distributions.presence",
),
geometryAssociation: numberRecord(
distributions.geometry_association,
"E37.dimension_distributions.geometry_association",
),
freshness: numberRecord(
distributions.freshness,
"E37.dimension_distributions.freshness",
),
},
severityDistribution: numberRecord(
item.severity_distribution,
"E37.severity_distribution",
),
labelProvenance: {
engineeringItems: integerValue(
provenance.engineering_items,
"E37.label_provenance.engineering_items",
),
humanExceptionItems: integerValue(
provenance.human_exception_items,
"E37.label_provenance.human_exception_items",
),
independentGroundTruth: false,
},
split: {
strategy: stringValue(split.strategy, "E37.split.strategy"),
validationFraction: numberValue(
split.validation_fraction,
"E37.split.validation_fraction",
),
},
targets: {
presenceTarget: numberValue(
targets.presence_target,
"E37.targets.presence_target",
),
geometryAssociationTarget: numberValue(
targets.geometry_association_target,
"E37.targets.geometry_association_target",
),
freshnessTarget: numberValue(
targets.freshness_target,
"E37.targets.freshness_target",
),
},
qualityTargetEvaluated: false,
limitations: strings(item.limitations, "E37.limitations"),
access: exactString(item.access, "read-only", "E37.access"),
};
}
function parseE38Dimension(
value: unknown,
label: string,
): E38DimensionMetric {
const source = record(value, label);
const byStratumSource = record(source.by_stratum, `${label}.by_stratum`);
if (!Array.isArray(source.confusion)) {
throw new AdvancedLaboratoryContractError(
`${label}.confusion: ожидался массив.`,
);
}
return {
correct: integerValue(source.correct, `${label}.correct`),
incorrect: integerValue(source.incorrect, `${label}.incorrect`),
total: integerValue(source.total, `${label}.total`),
accuracy: numberValue(source.accuracy, `${label}.accuracy`),
target: numberValue(source.target, `${label}.target`),
passed: booleanValue(source.passed, `${label}.passed`),
byStratum: Object.fromEntries(
Object.entries(byStratumSource).map(([stratum, value]) => {
const metric = record(value, `${label}.by_stratum.${stratum}`);
return [
stratum,
{
correct: integerValue(
metric.correct,
`${label}.by_stratum.${stratum}.correct`,
),
incorrect: integerValue(
metric.incorrect,
`${label}.by_stratum.${stratum}.incorrect`,
),
total: integerValue(
metric.total,
`${label}.by_stratum.${stratum}.total`,
),
accuracy: numberValue(
metric.accuracy,
`${label}.by_stratum.${stratum}.accuracy`,
),
},
];
}),
),
confusion: source.confusion.map((value, index) => {
const row = record(value, `${label}.confusion[${index}]`);
return {
reference: stringValue(
row.reference,
`${label}.confusion[${index}].reference`,
),
prediction: stringValue(
row.prediction,
`${label}.confusion[${index}].prediction`,
),
count: integerValue(
row.count,
`${label}.confusion[${index}].count`,
),
};
}),
};
}
export function parseE38(value: unknown): E38PerceptionBaselineResult {
const item = record(value, "E38");
const metrics = record(item.metrics, "E38.metrics");
const dimensions = record(metrics.dimensions, "E38.metrics.dimensions");
diagnosticAuthority(item.authority, "E38.authority");
return {
resultId: contentId(
item.result_id,
"e38-perception-baseline",
"E38.result_id",
),
createdAtUtc: optionalString(item.created_at_utc, "E38.created_at_utc"),
sourceSessionId: stringValue(
item.source_session_id,
"E38.source_session_id",
),
sourceDisplayName: stringValue(
item.source_display_name,
"E38.source_display_name",
),
status: exactString(
item.status,
"measured-r1-source-scoped-baseline",
"E38.status",
),
profileId: stringValue(item.profile_id, "E38.profile_id"),
workerNode: stringValue(item.worker_node, "E38.worker_node"),
qualityGatePassed: booleanValue(
item.quality_gate_passed,
"E38.quality_gate_passed",
),
metrics: {
developmentItems: integerValue(
metrics.development_items,
"E38.metrics.development_items",
),
validationItems: integerValue(
metrics.validation_items,
"E38.metrics.validation_items",
),
terminalOutcomes: integerValue(
metrics.terminal_outcomes,
"E38.metrics.terminal_outcomes",
),
accountingFraction: numberValue(
metrics.accounting_fraction,
"E38.metrics.accounting_fraction",
),
falseFreeClaims: integerValue(
metrics.false_free_claims,
"E38.metrics.false_free_claims",
),
highSeverityFailures: integerValue(
metrics.high_severity_failures,
"E38.metrics.high_severity_failures",
),
dimensions: {
presence: parseE38Dimension(
dimensions.presence,
"E38.metrics.dimensions.presence",
),
geometryAssociation: parseE38Dimension(
dimensions.geometry_association,
"E38.metrics.dimensions.geometry_association",
),
freshness: parseE38Dimension(
dimensions.freshness,
"E38.metrics.dimensions.freshness",
),
},
},
blockingChecks: strings(item.blocking_checks, "E38.blocking_checks"),
limitations: strings(item.limitations, "E38.limitations"),
access: exactString(item.access, "read-only", "E38.access"),
};
}
function parseDevelopmentDimension(
value: unknown,
label: string,
): DevelopmentDimensionMetric {
const source = record(value, label);
return {
correct: integerValue(source.correct, `${label}.correct`),
incorrect: integerValue(source.incorrect, `${label}.incorrect`),
total: integerValue(source.total, `${label}.total`),
accuracy: numberValue(source.accuracy, `${label}.accuracy`),
target: numberValue(source.target, `${label}.target`),
passed: booleanValue(source.passed, `${label}.passed`),
};
}
export function parseE39(value: unknown): E39PerceptionRefinementResult {
const item = record(value, "E39");
const metrics = record(item.metrics, "E39.metrics");
const dimensions = record(metrics.dimensions, "E39.metrics.dimensions");
const developmentCrossValidation = record(
item.development_cross_validation,
"E39.development_cross_validation",
);
const developmentDimensions = record(
developmentCrossValidation.dimensions,
"E39.development_cross_validation.dimensions",
);
const method = record(item.method, "E39.method");
diagnosticAuthority(item.authority, "E39.authority");
return {
resultId: contentId(
item.result_id,
"e39-perception-refinement",
"E39.result_id",
),
createdAtUtc: optionalString(item.created_at_utc, "E39.created_at_utc"),
sourceSessionId: stringValue(
item.source_session_id,
"E39.source_session_id",
),
sourceDisplayName: stringValue(
item.source_display_name,
"E39.source_display_name",
),
status: exactString(
item.status,
"measured-r1-source-scoped-refinement",
"E39.status",
),
profileId: stringValue(item.profile_id, "E39.profile_id"),
workerNode: stringValue(item.worker_node, "E39.worker_node"),
qualityGatePassed: booleanValue(
item.quality_gate_passed,
"E39.quality_gate_passed",
),
developmentCrossValidation: {
strategy: stringValue(
developmentCrossValidation.strategy,
"E39.development_cross_validation.strategy",
),
seed: stringValue(
developmentCrossValidation.seed,
"E39.development_cross_validation.seed",
),
folds: integerValue(
developmentCrossValidation.folds,
"E39.development_cross_validation.folds",
),
items: integerValue(
developmentCrossValidation.items,
"E39.development_cross_validation.items",
),
validationLabelsUsed: falseValue(
developmentCrossValidation.validation_labels_used,
"E39.development_cross_validation.validation_labels_used",
),
passed: booleanValue(
developmentCrossValidation.passed,
"E39.development_cross_validation.passed",
),
dimensions: {
presence: parseDevelopmentDimension(
developmentDimensions.presence,
"E39.development_cross_validation.dimensions.presence",
),
geometryAssociation: parseDevelopmentDimension(
developmentDimensions.geometry_association,
"E39.development_cross_validation.dimensions.geometry_association",
),
freshness: parseDevelopmentDimension(
developmentDimensions.freshness,
"E39.development_cross_validation.dimensions.freshness",
),
},
},
metrics: {
developmentItems: integerValue(
metrics.development_items,
"E39.metrics.development_items",
),
validationItems: integerValue(
metrics.validation_items,
"E39.metrics.validation_items",
),
terminalOutcomes: integerValue(
metrics.terminal_outcomes,
"E39.metrics.terminal_outcomes",
),
accountingFraction: numberValue(
metrics.accounting_fraction,
"E39.metrics.accounting_fraction",
),
falseFreeClaims: integerValue(
metrics.false_free_claims,
"E39.metrics.false_free_claims",
),
highSeverityFailures: integerValue(
metrics.high_severity_failures,
"E39.metrics.high_severity_failures",
),
dimensions: {
presence: parseE38Dimension(
dimensions.presence,
"E39.metrics.dimensions.presence",
),
geometryAssociation: parseE38Dimension(
dimensions.geometry_association,
"E39.metrics.dimensions.geometry_association",
),
freshness: parseE38Dimension(
dimensions.freshness,
"E39.metrics.dimensions.freshness",
),
},
},
blockingChecks: strings(item.blocking_checks, "E39.blocking_checks"),
method: {
summary: stringValue(method.summary, "E39.method.summary"),
selection: stringValue(method.selection, "E39.method.selection"),
dimensionProjection: stringValue(
method.dimension_projection,
"E39.method.dimension_projection",
),
},
limitations: strings(item.limitations, "E39.limitations"),
access: exactString(item.access, "read-only", "E39.access"),
};
}
export async function fetchOne<T>(
path: string,
parser: (value: unknown) => T,
fetcher: LaboratoryFetch,
signal?: AbortSignal,
): Promise<T | null> {
const response = await fetcher(path, {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) {
throw new AdvancedLaboratoryContractError(`Каталог LAB недоступен: HTTP ${response.status}.`);
}
return parseCatalog(await response.json(), parser);
}
export async function fetchAdvancedLaboratoryResults({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<AdvancedLaboratoryResults> {
const settled = await Promise.allSettled([
fetchOne("/api/v1/laboratory/e31/results?limit=1", parseE31, fetcher, signal),
fetchOne("/api/v1/laboratory/e32/results?limit=1", parseE32, fetcher, signal),
fetchOne("/api/v1/laboratory/e33/results?limit=1", parseE33, fetcher, signal),
fetchE34TemporalLayerResult({ fetcher, signal }),
fetchE35DegradationRecoveryResult({ fetcher, signal }),
fetchOne("/api/v1/laboratory/e37/results?limit=1", parseE37, fetcher, signal),
fetchOne("/api/v1/laboratory/e38/results?limit=1", parseE38, fetcher, signal),
fetchOne("/api/v1/laboratory/e39/results?limit=1", parseE39, fetcher, signal),
fetchE40ProductGateResult({ fetcher, signal }),
]);
const e31 = settledCatalogValue(settled[0]);
const e32 = settledCatalogValue(settled[1]);
const e33 = settledCatalogValue(settled[2]);
const e34 = settledCatalogValue(settled[3]);
const e35 = settledCatalogValue(settled[4]);
const e37 = settledCatalogValue(settled[5]);
const e38 = settledCatalogValue(settled[6]);
const e39 = settledCatalogValue(settled[7]);
const e40 = settledCatalogValue(settled[8]);
return {
m47Graph: null, m48: null, m48SmallStatic: null, m48StaticOccupancy: null,
m48r3StaticOccupancy: null,
m48s: null, m48t: null, m49Tgs: null, m49TgsFull: null, m4Threat: null,
l3: null, l31: null, l32: null, l33: null,
e31,
e32,
e33,
e34,
e35,
e37,
e38,
e39,
e40,
e46: null,
e46a: null,
e46b: null,
e46c: null,
e46d: null,
e46e: null, e46f: null,
e46g: null, e46h: null, e46i: null, e46j: null,
e47: null,
l34: null,
l34a: null,
l34b: null,
l34c: null,
l34d: null,
l34e: null,
l34f: null,
};
}
@@ -0,0 +1,17 @@
export function settledCatalogValue<T>(
result: PromiseSettledResult<T>,
): T | null {
if (result.status === "fulfilled") {
return result.value;
}
const reason = result.reason;
const isAbort = reason instanceof Error && reason.name === "AbortError";
const isHttpTransport = (
reason instanceof Error
&& /Каталог LAB.*недоступен: HTTP \d+\./.test(reason.message)
);
if (!isAbort && (reason instanceof TypeError || isHttpTransport)) {
return null;
}
throw reason;
}
@@ -0,0 +1,548 @@
export interface E29ReviewFrame {
frameIndex: number;
sourceFrameIndex: number;
sessionSeconds: number;
conflictCount: number;
semanticObservationCount: number;
geometryOnlyClusterCount: number;
}
export interface E29EvidenceResult {
resultId: string;
createdAtUtc: string | null;
status: "diagnostic-replay-complete";
identity: {
frameCount: number;
timelineStartSeconds: number;
timelineEndSeconds: number;
profileId: string;
producerSha256: string;
};
metrics: {
frames: {
total: number;
sourceAvailable: number;
localSurfaceValid: number;
};
semanticObservations: {
total: number;
current: number;
agreementFractionOfCurrent: number;
geometryStatus: {
agree: number;
cameraOnly: number;
conflict: number;
unknown: number;
};
};
geometryOnlyOccupied: {
clusterCount: number;
pointCount: number;
};
runtime: {
buildElapsedMs: number;
frameProcessingP95Ms: number;
};
};
decision: {
cameraFirstContractImplemented: boolean;
parallelGeometryOnlyLayerImplemented: boolean;
productionPromotion: boolean;
nextGate: string;
};
limitations: readonly string[];
authority: {
commandsEnabled: false;
navigationOrSafetyAccepted: false;
};
reviewFrames: readonly E29ReviewFrame[];
linkedEvidence: {
sourceSessionId: string;
localSurfaceModelId: string;
sourcePackId: string;
sourceResultId: string;
publishedOnControlPlane: true;
workerCopyRequired: false;
};
groundTruth: false;
}
export interface E29EvidenceCatalog {
configured: boolean;
candidateTotal: number;
invalidTotal: number;
items: readonly E29EvidenceResult[];
}
export interface E29SemanticObservation {
trackId: number;
label: string;
geometryStatus: string;
geometryReason: string;
rangeM: number | null;
score: number;
support: {
classifiedPoints: number;
occupiedPoints: number;
surfacePoints: number;
};
}
export interface E29GeometryOnlyCluster {
nearestRangeM: number;
pointCount: number;
voxelCount: number;
heightRangeM: readonly [number, number];
}
export interface E29EvidenceFrame {
resultId: string;
frameIndex: number;
sourceFrameIndex: number;
sessionSeconds: number;
semanticObservations: readonly E29SemanticObservation[];
geometryOnlyOccupied: readonly E29GeometryOnlyCluster[];
}
export class E29EvidenceContractError extends Error {
constructor(message: string) {
super(message);
this.name = "E29EvidenceContractError";
}
}
type E29Fetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new E29EvidenceContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function stringValue(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new E29EvidenceContractError(`${label}: ожидалась строка.`);
}
return value;
}
function numberValue(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new E29EvidenceContractError(`${label}: ожидалось число.`);
}
return value;
}
function integerValue(value: unknown, label: string): number {
const parsed = numberValue(value, label);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new E29EvidenceContractError(`${label}: ожидалось целое число.`);
}
return parsed;
}
function booleanValue(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new E29EvidenceContractError(`${label}: ожидался boolean.`);
}
return value;
}
function falseValue(value: unknown, label: string): false {
if (booleanValue(value, label) !== false) {
throw new E29EvidenceContractError(`${label}: ожидалось false.`);
}
return false;
}
function trueValue(value: unknown, label: string): true {
if (booleanValue(value, label) !== true) {
throw new E29EvidenceContractError(`${label}: ожидалось true.`);
}
return true;
}
function nullableNumber(value: unknown, label: string): number | null {
return value === null ? null : numberValue(value, label);
}
function tuple2(value: unknown, label: string): readonly [number, number] {
if (!Array.isArray(value) || value.length !== 2) {
throw new E29EvidenceContractError(`${label}: ожидалась пара чисел.`);
}
return [
numberValue(value[0], `${label}[0]`),
numberValue(value[1], `${label}[1]`),
];
}
function parseReviewFrame(value: unknown): E29ReviewFrame {
const source = record(value, "review_frame");
return {
frameIndex: integerValue(source.frame_index, "review_frame.frame_index"),
sourceFrameIndex: integerValue(
source.source_frame_index,
"review_frame.source_frame_index",
),
sessionSeconds: numberValue(
source.session_seconds,
"review_frame.session_seconds",
),
conflictCount: integerValue(
source.conflict_count,
"review_frame.conflict_count",
),
semanticObservationCount: integerValue(
source.semantic_observation_count,
"review_frame.semantic_observation_count",
),
geometryOnlyClusterCount: integerValue(
source.geometry_only_cluster_count,
"review_frame.geometry_only_cluster_count",
),
};
}
function parseEvidenceResult(value: unknown): E29EvidenceResult {
const source = record(value, "E29 result");
const identity = record(source.identity, "E29 identity");
const profile = record(identity.profile, "E29 identity.profile");
const metrics = record(source.metrics, "E29 metrics");
const frames = record(metrics.frames, "E29 metrics.frames");
const semantic = record(
metrics.semantic_observations,
"E29 metrics.semantic_observations",
);
const geometryStatus = record(
semantic.geometry_status,
"E29 metrics.semantic_observations.geometry_status",
);
const geometryOnly = record(
metrics.geometry_only_occupied,
"E29 metrics.geometry_only_occupied",
);
const runtime = record(metrics.runtime, "E29 metrics.runtime");
const frameProcessing = record(
runtime.frame_processing_ms,
"E29 metrics.runtime.frame_processing_ms",
);
const decision = record(source.decision, "E29 decision");
const authority = record(source.authority, "E29 authority");
const linked = record(source.linked_evidence, "E29 linked evidence");
const status = stringValue(source.status, "E29 status");
if (status !== "diagnostic-replay-complete") {
throw new E29EvidenceContractError("E29 status не завершён.");
}
if (!Array.isArray(source.limitations) || !Array.isArray(source.review_frames)) {
throw new E29EvidenceContractError("E29 collections имеют неверный формат.");
}
return {
resultId: stringValue(source.result_id, "E29 result_id"),
createdAtUtc: source.created_at_utc === null
? null
: stringValue(source.created_at_utc, "E29 created_at_utc"),
status,
identity: {
frameCount: integerValue(identity.frame_count, "E29 identity.frame_count"),
timelineStartSeconds: numberValue(
identity.timeline_start_seconds,
"E29 identity.timeline_start_seconds",
),
timelineEndSeconds: numberValue(
identity.timeline_end_seconds,
"E29 identity.timeline_end_seconds",
),
profileId: stringValue(profile.profile_id, "E29 identity.profile.profile_id"),
producerSha256: stringValue(
identity.producer_sha256,
"E29 identity.producer_sha256",
),
},
metrics: {
frames: {
total: integerValue(frames.total, "E29 metrics.frames.total"),
sourceAvailable: integerValue(
frames.source_available,
"E29 metrics.frames.source_available",
),
localSurfaceValid: integerValue(
frames.local_surface_valid,
"E29 metrics.frames.local_surface_valid",
),
},
semanticObservations: {
total: integerValue(
semantic.total,
"E29 metrics.semantic_observations.total",
),
current: integerValue(
semantic.current,
"E29 metrics.semantic_observations.current",
),
agreementFractionOfCurrent: numberValue(
semantic.agreement_fraction_of_current,
"E29 metrics.semantic_observations.agreement_fraction_of_current",
),
geometryStatus: {
agree: integerValue(geometryStatus.agree, "E29 geometry_status.agree"),
cameraOnly: integerValue(
geometryStatus["single-source-camera"],
"E29 geometry_status.single-source-camera",
),
conflict: integerValue(
geometryStatus.conflict,
"E29 geometry_status.conflict",
),
unknown: integerValue(
geometryStatus.unknown,
"E29 geometry_status.unknown",
),
},
},
geometryOnlyOccupied: {
clusterCount: integerValue(
geometryOnly.cluster_count,
"E29 geometry_only_occupied.cluster_count",
),
pointCount: integerValue(
geometryOnly.point_count,
"E29 geometry_only_occupied.point_count",
),
},
runtime: {
buildElapsedMs: numberValue(
runtime.build_elapsed_ms,
"E29 runtime.build_elapsed_ms",
),
frameProcessingP95Ms: numberValue(
frameProcessing.p95,
"E29 runtime.frame_processing_ms.p95",
),
},
},
decision: {
cameraFirstContractImplemented: booleanValue(
decision.camera_first_contract_implemented,
"E29 decision.camera_first_contract_implemented",
),
parallelGeometryOnlyLayerImplemented: booleanValue(
decision.parallel_geometry_only_layer_implemented,
"E29 decision.parallel_geometry_only_layer_implemented",
),
productionPromotion: booleanValue(
decision.production_promotion,
"E29 decision.production_promotion",
),
nextGate: stringValue(decision.next_gate, "E29 decision.next_gate"),
},
limitations: source.limitations.map((item, index) => (
stringValue(item, `E29 limitations[${index}]`)
)),
authority: {
commandsEnabled: falseValue(
authority.commands_enabled,
"E29 authority.commands_enabled",
),
navigationOrSafetyAccepted: falseValue(
authority.navigation_or_safety_accepted,
"E29 authority.navigation_or_safety_accepted",
),
},
reviewFrames: source.review_frames.map(parseReviewFrame),
linkedEvidence: {
sourceSessionId: stringValue(
linked.source_session_id,
"E29 linked_evidence.source_session_id",
),
localSurfaceModelId: stringValue(
linked.local_surface_model_id,
"E29 linked_evidence.local_surface_model_id",
),
sourcePackId: stringValue(
linked.source_pack_id,
"E29 linked_evidence.source_pack_id",
),
sourceResultId: stringValue(
linked.source_result_id,
"E29 linked_evidence.source_result_id",
),
publishedOnControlPlane: trueValue(
linked.published_on_control_plane,
"E29 linked_evidence.published_on_control_plane",
),
workerCopyRequired: falseValue(
linked.worker_copy_required,
"E29 linked_evidence.worker_copy_required",
),
},
groundTruth: falseValue(source.ground_truth, "E29 ground_truth"),
};
}
export function parseE29EvidenceCatalog(value: unknown): E29EvidenceCatalog {
const source = record(value, "E29 catalog");
if (source.schema_version !== "missioncore.laboratory-e29-catalog/v1") {
throw new E29EvidenceContractError("E29 catalog schema не поддерживается.");
}
if (!Array.isArray(source.items)) {
throw new E29EvidenceContractError("E29 catalog.items имеет неверный формат.");
}
return {
configured: booleanValue(source.configured, "E29 catalog.configured"),
candidateTotal: integerValue(
source.candidate_total,
"E29 catalog.candidate_total",
),
invalidTotal: integerValue(
source.invalid_total,
"E29 catalog.invalid_total",
),
items: source.items.map(parseEvidenceResult),
};
}
function parseSemanticObservation(value: unknown): E29SemanticObservation {
const source = record(value, "E29 semantic observation");
const support = record(source.support, "E29 semantic observation.support");
return {
trackId: integerValue(source.track_id, "E29 semantic observation.track_id"),
label: stringValue(source.label, "E29 semantic observation.label"),
geometryStatus: stringValue(
source.geometry_status,
"E29 semantic observation.geometry_status",
),
geometryReason: stringValue(
source.geometry_reason,
"E29 semantic observation.geometry_reason",
),
rangeM: nullableNumber(source.range_m, "E29 semantic observation.range_m"),
score: numberValue(source.score, "E29 semantic observation.score"),
support: {
classifiedPoints: integerValue(
support.classified_points_in_bbox,
"E29 support.classified_points_in_bbox",
),
occupiedPoints: integerValue(
support.occupied_points_in_bbox,
"E29 support.occupied_points_in_bbox",
),
surfacePoints: integerValue(
support.surface_points_in_bbox,
"E29 support.surface_points_in_bbox",
),
},
};
}
function parseGeometryOnly(value: unknown): E29GeometryOnlyCluster {
const source = record(value, "E29 geometry-only cluster");
return {
nearestRangeM: numberValue(
source.nearest_range_m,
"E29 geometry-only.nearest_range_m",
),
pointCount: integerValue(
source.point_count,
"E29 geometry-only.point_count",
),
voxelCount: integerValue(
source.voxel_count,
"E29 geometry-only.voxel_count",
),
heightRangeM: tuple2(
source.height_range_m,
"E29 geometry-only.height_range_m",
),
};
}
export function parseE29EvidenceFrame(value: unknown): E29EvidenceFrame {
const source = record(value, "E29 frame response");
if (source.schema_version !== "missioncore.laboratory-e29-frame/v1") {
throw new E29EvidenceContractError("E29 frame schema не поддерживается.");
}
const frame = record(source.frame, "E29 frame");
if (
frame.schema_version !== "missioncore.e29-camera-geometry-frame/v1"
|| !Array.isArray(frame.semantic_observations)
|| !Array.isArray(frame.geometry_only_occupied)
) {
throw new E29EvidenceContractError("E29 frame имеет неверный формат.");
}
return {
resultId: stringValue(source.result_id, "E29 frame result_id"),
frameIndex: integerValue(frame.frame_index, "E29 frame.frame_index"),
sourceFrameIndex: integerValue(
frame.source_frame_index,
"E29 frame.source_frame_index",
),
sessionSeconds: numberValue(
frame.session_seconds,
"E29 frame.session_seconds",
),
semanticObservations: frame.semantic_observations.map(
parseSemanticObservation,
),
geometryOnlyOccupied: frame.geometry_only_occupied.map(parseGeometryOnly),
};
}
async function responseJson(response: Response, fallback: string): Promise<unknown> {
let payload: unknown = null;
try {
payload = await response.json();
} catch {
// Preserve the status-aware fallback below.
}
if (!response.ok) {
const detail = payload && typeof payload === "object" && "detail" in payload
? String((payload as { detail?: unknown }).detail)
: fallback;
throw new E29EvidenceContractError(detail);
}
return payload;
}
export async function fetchE29EvidenceCatalog(
options: { signal?: AbortSignal; fetcher?: E29Fetch } = {},
): Promise<E29EvidenceCatalog> {
const fetcher = options.fetcher ?? fetch;
const response = await fetcher("/api/v1/laboratory/e29/results?limit=1", {
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
});
return parseE29EvidenceCatalog(
await responseJson(response, "Не удалось получить LAB E29."),
);
}
export async function fetchE29EvidenceFrame(
resultId: string,
frameIndex: number,
options: { signal?: AbortSignal; fetcher?: E29Fetch } = {},
): Promise<E29EvidenceFrame> {
if (
!/^e29-camera-geometry-[a-f0-9]{64}$/.test(resultId)
|| !Number.isSafeInteger(frameIndex)
|| frameIndex < 0
) {
throw new E29EvidenceContractError("Некорректный LAB E29 frame id.");
}
const fetcher = options.fetcher ?? fetch;
const response = await fetcher(
`/api/v1/laboratory/e29/results/${resultId}/frames/${frameIndex}`,
{
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
},
);
return parseE29EvidenceFrame(
await responseJson(response, "Не удалось получить кадр LAB E29."),
);
}
@@ -0,0 +1,659 @@
import {
parseE30ReviewItem,
type E30ReviewItem,
type E30Stratum,
} from "./e30Review";
export type E30EngineeringVerdict =
| "confirmed"
| "corrected"
| "insufficient-evidence";
export type E30DetectorAssessment =
| "valid"
| "class-mismatch"
| "false-positive"
| "missed-object"
| "not-applicable"
| "insufficient-evidence";
export type E30ProjectionAssessment =
| "aligned"
| "misaligned"
| "not-assessable";
export type E30PointOwnership =
| "object"
| "surface-or-background"
| "static-environment"
| "self"
| "insufficient-support"
| "not-applicable"
| "mixed"
| "insufficient-evidence";
export interface E30ExceptionReviewPrompt {
question: string;
focus: string;
effects: Readonly<{
"object-present": string;
"background-or-noise": string;
"insufficient-evidence": string;
}>;
}
export interface E30EngineeringGeneration {
generationId: string;
createdAtUtc: string;
materializationId: string;
producer: {
producerId: string;
methodId: string;
reviewSheetIdentitySha256: string;
claimsHumanGroundTruth: false;
};
summary: {
itemCount: number;
reviewedItemCount: number;
verdictDistribution: Readonly<Record<string, number>>;
detectorDistribution: Readonly<Record<string, number>>;
projectionDistribution: Readonly<Record<string, number>>;
pointOwnershipDistribution: Readonly<Record<string, number>>;
humanExceptionCount: number;
meanConfidence: number;
};
causeDistribution: readonly {
reasonCode: string;
count: number;
}[];
humanExceptions: readonly {
itemId: string;
reviewKey: string;
sourceStratum: E30Stratum;
confidence: number;
reviewPrompt: E30ExceptionReviewPrompt | null;
}[];
aiReviewComplete: true;
humanExceptionComplete: boolean;
humanReviewComplete: false;
labPublished: false;
access: "read-only";
}
export interface E30EngineeringCatalog {
configured: boolean;
candidateTotal: number;
invalidTotal: number;
items: readonly E30EngineeringGeneration[];
}
export interface E30EngineeringDecision {
sequence: number;
itemId: string;
reviewKey: string;
sourceStratum: E30Stratum;
verdict: E30EngineeringVerdict;
effectiveStratum: E30Stratum | null;
detectorAssessment: E30DetectorAssessment;
projectionAssessment: E30ProjectionAssessment;
pointOwnership: E30PointOwnership;
causeCode: string | null;
confidence: number;
humanExceptionRequired: boolean;
exceptionReason: "ambiguity" | "high-impact" | null;
evidenceNote: string;
reviewSheet: {
path: string;
sha256: string;
ordinal: number;
};
}
export interface E30EngineeringExceptions {
resultId: string;
generationId: string;
items: readonly E30ReviewItem[];
total: number;
nextCursor: number | null;
}
export class E30EngineeringContractError extends Error {
constructor(message: string) {
super(message);
this.name = "E30EngineeringContractError";
}
}
type E30Fetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new E30EngineeringContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function stringValue(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new E30EngineeringContractError(`${label}: ожидалась строка.`);
}
return value;
}
function integerValue(value: unknown, label: string): number {
if (
typeof value !== "number"
|| !Number.isSafeInteger(value)
|| value < 0
) {
throw new E30EngineeringContractError(`${label}: ожидалось целое число.`);
}
return value;
}
function numberValue(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new E30EngineeringContractError(`${label}: ожидалось число.`);
}
return value;
}
function booleanValue(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new E30EngineeringContractError(`${label}: ожидался boolean.`);
}
return value;
}
function falseValue(value: unknown, label: string): false {
if (value !== false) {
throw new E30EngineeringContractError(`${label}: ожидалось false.`);
}
return false;
}
function trueValue(value: unknown, label: string): true {
if (value !== true) {
throw new E30EngineeringContractError(`${label}: ожидалось true.`);
}
return true;
}
function contentId(value: unknown, prefix: string, label: string): string {
const parsed = stringValue(value, label);
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
throw new E30EngineeringContractError(`${label}: неверный content id.`);
}
return parsed;
}
function readOnly(value: unknown, label: string): "read-only" {
if (value !== "read-only") {
throw new E30EngineeringContractError(`${label}: ожидалось read-only.`);
}
return "read-only";
}
function authority(value: unknown, label: string): void {
const parsed = record(value, label);
falseValue(parsed.commands_enabled, `${label}.commands_enabled`);
falseValue(
parsed.navigation_or_safety_accepted,
`${label}.navigation_or_safety_accepted`,
);
}
function distribution(
value: unknown,
label: string,
): Readonly<Record<string, number>> {
const parsed = record(value, label);
return Object.fromEntries(
Object.entries(parsed).map(([key, count]) => [
key,
integerValue(count, `${label}.${key}`),
]),
);
}
function stratum(value: unknown, label: string): E30Stratum {
if (
value !== "conflict"
&& value !== "agree"
&& value !== "camera-only"
&& value !== "unknown"
&& value !== "geometry-only"
) {
throw new E30EngineeringContractError(`${label}: неизвестная страта.`);
}
return value;
}
function nullableStratum(value: unknown, label: string): E30Stratum | null {
return value === null ? null : stratum(value, label);
}
function enumValue<T extends string>(
value: unknown,
values: readonly T[],
label: string,
): T {
if (typeof value !== "string" || !values.includes(value as T)) {
throw new E30EngineeringContractError(`${label}: неизвестное значение.`);
}
return value as T;
}
function exceptionReviewPrompt(
value: unknown,
label: string,
): E30ExceptionReviewPrompt | null {
if (value === null || value === undefined) return null;
const source = record(value, label);
const effects = record(source.effects, `${label}.effects`);
return {
question: stringValue(source.question, `${label}.question`),
focus: stringValue(source.focus, `${label}.focus`),
effects: {
"object-present": stringValue(
effects["object-present"],
`${label}.effects.object-present`,
),
"background-or-noise": stringValue(
effects["background-or-noise"],
`${label}.effects.background-or-noise`,
),
"insufficient-evidence": stringValue(
effects["insufficient-evidence"],
`${label}.effects.insufficient-evidence`,
),
},
};
}
function parseGeneration(value: unknown): E30EngineeringGeneration {
const source = record(value, "A3 generation");
const producer = record(source.producer, "A3 producer");
const summary = record(source.summary, "A3 summary");
const causes = record(source.cause_distribution, "A3 causes");
const humanExceptions = source.human_exceptions;
if (
producer.kind !== "ai-assisted-engineering-review"
|| !Array.isArray(causes.reasons)
|| !Array.isArray(humanExceptions)
) {
throw new E30EngineeringContractError("A3 provenance не поддерживается.");
}
falseValue(
producer.claims_human_ground_truth,
"A3 producer.claims_human_ground_truth",
);
authority(source.authority, "A3 authority");
return {
generationId: contentId(
source.generation_id,
"e30-engineering-generation",
"A3 generation_id",
),
createdAtUtc: stringValue(source.created_at_utc, "A3 created_at_utc"),
materializationId: contentId(
source.materialization_id,
"e30-materialization",
"A3 materialization_id",
),
producer: {
producerId: stringValue(producer.producer_id, "A3 producer_id"),
methodId: stringValue(producer.method_id, "A3 method_id"),
reviewSheetIdentitySha256: stringValue(
producer.review_sheet_identity_sha256,
"A3 review_sheet_identity_sha256",
),
claimsHumanGroundTruth: false,
},
summary: {
itemCount: integerValue(summary.item_count, "A3 item_count"),
reviewedItemCount: integerValue(
summary.reviewed_item_count,
"A3 reviewed_item_count",
),
verdictDistribution: distribution(
summary.verdict_distribution,
"A3 verdict_distribution",
),
detectorDistribution: distribution(
summary.detector_distribution,
"A3 detector_distribution",
),
projectionDistribution: distribution(
summary.projection_distribution,
"A3 projection_distribution",
),
pointOwnershipDistribution: distribution(
summary.point_ownership_distribution,
"A3 point_ownership_distribution",
),
humanExceptionCount: integerValue(
summary.human_exception_count,
"A3 human_exception_count",
),
meanConfidence: numberValue(
summary.mean_confidence,
"A3 mean_confidence",
),
},
causeDistribution: causes.reasons.map((value, index) => {
const reason = record(value, `A3 causes[${index}]`);
return {
reasonCode: stringValue(
reason.reason_code,
`A3 causes[${index}].reason_code`,
),
count: integerValue(reason.count, `A3 causes[${index}].count`),
};
}),
humanExceptions: humanExceptions.map((value, index) => {
const exception = record(value, `A3 exceptions[${index}]`);
const confidence = numberValue(
exception.confidence,
`A3 exceptions[${index}].confidence`,
);
if (confidence < 0 || confidence > 1) {
throw new E30EngineeringContractError(
`A3 exceptions[${index}].confidence вне диапазона.`,
);
}
return {
itemId: contentId(
exception.item_id,
"e30-review-item",
`A3 exceptions[${index}].item_id`,
),
reviewKey: stringValue(
exception.review_key,
`A3 exceptions[${index}].review_key`,
),
sourceStratum: stratum(
exception.source_stratum,
`A3 exceptions[${index}].source_stratum`,
),
confidence,
reviewPrompt: exceptionReviewPrompt(
exception.review_prompt,
`A3 exceptions[${index}].review_prompt`,
),
};
}),
aiReviewComplete: trueValue(
source.ai_review_complete,
"A3 ai_review_complete",
),
humanExceptionComplete: booleanValue(
source.human_exception_complete,
"A3 human_exception_complete",
),
humanReviewComplete: falseValue(
source.human_review_complete,
"A3 human_review_complete",
),
labPublished: falseValue(source.lab_published, "A3 lab_published"),
access: readOnly(source.access, "A3 access"),
};
}
export function parseE30EngineeringCatalog(
value: unknown,
): E30EngineeringCatalog {
const source = record(value, "A3 catalog");
if (
source.schema_version
!== "missioncore.laboratory-e30-engineering-generations/v1"
|| !Array.isArray(source.items)
) {
throw new E30EngineeringContractError("A3 catalog schema не поддерживается.");
}
readOnly(source.access, "A3 catalog access");
return {
configured: Boolean(source.configured),
candidateTotal: integerValue(
source.candidate_total,
"A3 candidate_total",
),
invalidTotal: integerValue(source.invalid_total, "A3 invalid_total"),
items: source.items.map(parseGeneration),
};
}
export function parseE30EngineeringDecision(
value: unknown,
): E30EngineeringDecision {
const response = record(value, "A3 decision response");
if (
response.schema_version
!== "missioncore.laboratory-e30-engineering-decision/v1"
) {
throw new E30EngineeringContractError("A3 decision schema не поддерживается.");
}
readOnly(response.access, "A3 decision access");
contentId(
response.generation_id,
"e30-engineering-generation",
"A3 decision generation_id",
);
const source = record(response.decision, "A3 decision");
const sheet = record(source.review_sheet, "A3 review_sheet");
const confidence = numberValue(source.confidence, "A3 confidence");
if (confidence < 0 || confidence > 1) {
throw new E30EngineeringContractError("A3 confidence вне диапазона.");
}
const exceptionReason = source.exception_reason === null
? null
: enumValue(
source.exception_reason,
["ambiguity", "high-impact"] as const,
"A3 exception_reason",
);
return {
sequence: integerValue(source.sequence, "A3 sequence"),
itemId: contentId(
source.item_id,
"e30-review-item",
"A3 decision item_id",
),
reviewKey: stringValue(source.review_key, "A3 review_key"),
sourceStratum: stratum(source.source_stratum, "A3 source_stratum"),
verdict: enumValue(
source.verdict,
["confirmed", "corrected", "insufficient-evidence"] as const,
"A3 verdict",
),
effectiveStratum: nullableStratum(
source.effective_stratum,
"A3 effective_stratum",
),
detectorAssessment: enumValue(
source.detector_assessment,
[
"valid",
"class-mismatch",
"false-positive",
"missed-object",
"not-applicable",
"insufficient-evidence",
] as const,
"A3 detector_assessment",
),
projectionAssessment: enumValue(
source.projection_assessment,
["aligned", "misaligned", "not-assessable"] as const,
"A3 projection_assessment",
),
pointOwnership: enumValue(
source.point_ownership,
[
"object",
"surface-or-background",
"static-environment",
"self",
"insufficient-support",
"not-applicable",
"mixed",
"insufficient-evidence",
] as const,
"A3 point_ownership",
),
causeCode: source.cause_code === null
? null
: stringValue(source.cause_code, "A3 cause_code"),
confidence,
humanExceptionRequired: booleanValue(
source.human_exception_required,
"A3 human_exception_required",
),
exceptionReason,
evidenceNote: stringValue(source.evidence_note, "A3 evidence_note"),
reviewSheet: {
path: stringValue(sheet.path, "A3 review_sheet.path"),
sha256: stringValue(sheet.sha256, "A3 review_sheet.sha256"),
ordinal: integerValue(sheet.ordinal, "A3 review_sheet.ordinal"),
},
};
}
export function parseE30EngineeringExceptions(
value: unknown,
): E30EngineeringExceptions {
const source = record(value, "A3 exception queue");
if (
source.schema_version
!== "missioncore.laboratory-e30-engineering-exceptions/v1"
|| !Array.isArray(source.items)
) {
throw new E30EngineeringContractError(
"A3 exception queue schema не поддерживается.",
);
}
readOnly(source.access, "A3 exception queue access");
return {
resultId: contentId(
source.result_id,
"e30-materialization",
"A3 exception queue result_id",
),
generationId: contentId(
source.generation_id,
"e30-engineering-generation",
"A3 exception queue generation_id",
),
items: source.items.map(parseE30ReviewItem),
total: integerValue(source.total, "A3 exception queue total"),
nextCursor: source.next_cursor === null
? null
: integerValue(
source.next_cursor,
"A3 exception queue next_cursor",
),
};
}
async function responseJson(response: Response, fallback: string): Promise<unknown> {
let payload: unknown = null;
try {
payload = await response.json();
} catch {
// Preserve the status-aware fallback below.
}
if (!response.ok) {
const detail = payload && typeof payload === "object" && "detail" in payload
? String((payload as { detail?: unknown }).detail)
: fallback;
throw new E30EngineeringContractError(detail);
}
return payload;
}
function validMaterializationId(value: string): boolean {
return /^e30-materialization-[a-f0-9]{64}$/.test(value);
}
function validGenerationId(value: string): boolean {
return /^e30-engineering-generation-[a-f0-9]{64}$/.test(value);
}
function validItemId(value: string): boolean {
return /^e30-review-item-[a-f0-9]{64}$/.test(value);
}
export async function fetchE30EngineeringCatalog(
resultId: string,
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
): Promise<E30EngineeringCatalog> {
if (!validMaterializationId(resultId)) {
throw new E30EngineeringContractError("Некорректный E30 materialization id.");
}
const fetcher = options.fetcher ?? fetch;
const response = await fetcher(
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations?limit=1`,
{
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
},
);
return parseE30EngineeringCatalog(
await responseJson(response, "Не удалось получить A3 generation."),
);
}
export async function fetchE30EngineeringDecision(
resultId: string,
generationId: string,
itemId: string,
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
): Promise<E30EngineeringDecision> {
if (
!validMaterializationId(resultId)
|| !validGenerationId(generationId)
|| !validItemId(itemId)
) {
throw new E30EngineeringContractError("Некорректный A3 decision id.");
}
const fetcher = options.fetcher ?? fetch;
const response = await fetcher(
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations/`
+ `${generationId}/items/${itemId}`,
{
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
},
);
return parseE30EngineeringDecision(
await responseJson(response, "Не удалось получить A3 decision."),
);
}
export async function fetchE30EngineeringExceptions(
resultId: string,
generationId: string,
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
): Promise<E30EngineeringExceptions> {
if (!validMaterializationId(resultId) || !validGenerationId(generationId)) {
throw new E30EngineeringContractError("Некорректная A3 exception queue.");
}
const fetcher = options.fetcher ?? fetch;
const response = await fetcher(
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations/`
+ `${generationId}/exceptions?limit=128`,
{
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
},
);
return parseE30EngineeringExceptions(
await responseJson(response, "Не удалось получить A3 exception queue."),
);
}
@@ -0,0 +1,459 @@
import {
E30ReviewContractError,
type E30Stratum,
} from "./e30Review";
export const E30_EXCEPTION_DISPOSITIONS = [
"object-present",
"background-or-noise",
"insufficient-evidence",
] as const;
export type E30ExceptionDisposition =
(typeof E30_EXCEPTION_DISPOSITIONS)[number];
export type E30HumanReviewState = "active" | "finalized";
export interface E30HumanReviewDecision {
itemId: string;
sourceStratum: E30Stratum;
disposition: E30ExceptionDisposition;
notes: string | null;
eventId: string;
decidedAtUtc: string;
}
export interface E30HumanReviewDraft {
draftId: string;
materializationId: string;
engineeringGenerationId: string;
reviewerId: string;
createdAtUtc: string;
state: E30HumanReviewState;
revision: number;
itemCount: number;
reviewedItemCount: number;
remainingItemCount: number;
dispositionDistribution: Readonly<Record<string, number>>;
generationId: string | null;
decisions: readonly E30HumanReviewDecision[];
labPublished: false;
access: "review-write" | "read-only";
}
export interface E30HumanReviewGeneration {
generationId: string;
materializationId: string;
engineeringGenerationId: string;
reviewerId: string;
createdAtUtc: string;
itemCount: number;
dispositionDistribution: Readonly<Record<string, number>>;
coverage: {
expectedItemCount: number;
reviewedItemCount: number;
complete: true;
};
humanReviewComplete: true;
labPublished: false;
access: "read-only";
}
export interface E30HumanReviewFinalized {
draft: E30HumanReviewDraft;
generation: E30HumanReviewGeneration;
}
type E30Fetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new E30ReviewContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function stringValue(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new E30ReviewContractError(`${label}: ожидалась строка.`);
}
return value;
}
function integerValue(value: unknown, label: string): number {
if (
typeof value !== "number"
|| !Number.isSafeInteger(value)
|| value < 0
) {
throw new E30ReviewContractError(`${label}: ожидалось целое число.`);
}
return value;
}
function contentId(value: unknown, prefix: string, label: string): string {
const parsed = stringValue(value, label);
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
throw new E30ReviewContractError(`${label}: некорректный content id.`);
}
return parsed;
}
function falseValue(value: unknown, label: string): false {
if (value !== false) {
throw new E30ReviewContractError(`${label}: ожидалось false.`);
}
return false;
}
function trueValue(value: unknown, label: string): true {
if (value !== true) {
throw new E30ReviewContractError(`${label}: ожидалось true.`);
}
return true;
}
function nullableString(value: unknown, label: string): string | null {
return value === null ? null : stringValue(value, label);
}
function distribution(
value: unknown,
label: string,
): Readonly<Record<string, number>> {
const source = record(value, label);
return Object.fromEntries(
Object.entries(source).map(([key, count]) => [
key,
integerValue(count, `${label}.${key}`),
]),
);
}
function disposition(
value: unknown,
label: string,
): E30ExceptionDisposition {
if (
typeof value !== "string"
|| !E30_EXCEPTION_DISPOSITIONS.includes(
value as E30ExceptionDisposition,
)
) {
throw new E30ReviewContractError(`${label}: неизвестное решение.`);
}
return value as E30ExceptionDisposition;
}
function stratum(value: unknown, label: string): E30Stratum {
if (
value !== "conflict"
&& value !== "agree"
&& value !== "camera-only"
&& value !== "unknown"
&& value !== "geometry-only"
) {
throw new E30ReviewContractError(`${label}: неизвестная страта.`);
}
return value;
}
function parseDecision(value: unknown): E30HumanReviewDecision {
const source = record(value, "Решение по исключению");
return {
itemId: contentId(
source.item_id,
"e30-review-item",
"Решение.item_id",
),
sourceStratum: stratum(source.source_stratum, "Решение.source_stratum"),
disposition: disposition(source.disposition, "Решение.disposition"),
notes: nullableString(source.notes, "Решение.notes"),
eventId: contentId(
source.event_id,
"e30-review-event",
"Решение.event_id",
),
decidedAtUtc: stringValue(
source.decided_at_utc,
"Решение.decided_at_utc",
),
};
}
export function parseE30HumanReviewDraft(
value: unknown,
): E30HumanReviewDraft {
const source = record(value, "Проверка исключений");
if (
source.schema_version !== "missioncore.e30-human-review-draft/v2"
|| !Array.isArray(source.decisions)
) {
throw new E30ReviewContractError(
"Схема проверки исключений не поддерживается.",
);
}
const state = stringValue(source.state, "Проверка.state");
const access = stringValue(source.access, "Проверка.access");
if (
(state !== "active" && state !== "finalized")
|| (state === "active" && access !== "review-write")
|| (state === "finalized" && access !== "read-only")
) {
throw new E30ReviewContractError("Состояние проверки некорректно.");
}
const itemCount = integerValue(source.item_count, "Проверка.item_count");
const reviewedItemCount = integerValue(
source.reviewed_item_count,
"Проверка.reviewed_item_count",
);
const remainingItemCount = integerValue(
source.remaining_item_count,
"Проверка.remaining_item_count",
);
const decisions = source.decisions.map(parseDecision);
if (
reviewedItemCount + remainingItemCount !== itemCount
|| decisions.length !== reviewedItemCount
) {
throw new E30ReviewContractError("Покрытие проверки расходится.");
}
return {
draftId: contentId(
source.draft_id,
"e30-human-draft",
"Проверка.draft_id",
),
materializationId: contentId(
source.materialization_id,
"e30-materialization",
"Проверка.materialization_id",
),
engineeringGenerationId: contentId(
source.engineering_generation_id,
"e30-engineering-generation",
"Проверка.engineering_generation_id",
),
reviewerId: stringValue(source.reviewer_id, "Проверка.reviewer_id"),
createdAtUtc: stringValue(source.created_at_utc, "Проверка.created_at_utc"),
state,
revision: integerValue(source.revision, "Проверка.revision"),
itemCount,
reviewedItemCount,
remainingItemCount,
dispositionDistribution: distribution(
source.disposition_distribution,
"Проверка.disposition_distribution",
),
generationId: source.generation_id === null
? null
: contentId(
source.generation_id,
"e30-review-generation",
"Проверка.generation_id",
),
decisions,
labPublished: falseValue(source.lab_published, "Проверка.lab_published"),
access: access as "review-write" | "read-only",
};
}
function parseGeneration(value: unknown): E30HumanReviewGeneration {
const source = record(value, "Зафиксированная проверка");
const coverage = record(source.coverage, "Проверка.coverage");
if (
source.schema_version !== "missioncore.e30-human-review-generation/v2"
|| source.access !== "read-only"
) {
throw new E30ReviewContractError(
"Схема зафиксированной проверки не поддерживается.",
);
}
return {
generationId: contentId(
source.generation_id,
"e30-review-generation",
"Проверка.generation_id",
),
materializationId: contentId(
source.materialization_id,
"e30-materialization",
"Проверка.materialization_id",
),
engineeringGenerationId: contentId(
source.engineering_generation_id,
"e30-engineering-generation",
"Проверка.engineering_generation_id",
),
reviewerId: stringValue(source.reviewer_id, "Проверка.reviewer_id"),
createdAtUtc: stringValue(source.created_at_utc, "Проверка.created_at_utc"),
itemCount: integerValue(source.item_count, "Проверка.item_count"),
dispositionDistribution: distribution(
source.disposition_distribution,
"Проверка.disposition_distribution",
),
coverage: {
expectedItemCount: integerValue(
coverage.expected_item_count,
"Проверка.coverage.expected",
),
reviewedItemCount: integerValue(
coverage.reviewed_item_count,
"Проверка.coverage.reviewed",
),
complete: trueValue(coverage.complete, "Проверка.coverage.complete"),
},
humanReviewComplete: trueValue(
source.human_review_complete,
"Проверка.human_review_complete",
),
labPublished: falseValue(source.lab_published, "Проверка.lab_published"),
access: "read-only",
};
}
export function parseE30HumanReviewFinalized(
value: unknown,
): E30HumanReviewFinalized {
const source = record(value, "Фиксация проверки");
if (
source.schema_version
!== "missioncore.laboratory-e30-human-review-finalized/v2"
) {
throw new E30ReviewContractError("Схема фиксации не поддерживается.");
}
const draft = parseE30HumanReviewDraft(source.draft);
const generation = parseGeneration(source.generation);
if (
draft.state !== "finalized"
|| draft.generationId !== generation.generationId
|| draft.engineeringGenerationId !== generation.engineeringGenerationId
) {
throw new E30ReviewContractError("Связь фиксации расходится.");
}
return { draft, generation };
}
async function responseJson(response: Response, fallback: string): Promise<unknown> {
let payload: unknown = null;
try {
payload = await response.json();
} catch {
// Preserve the status-aware fallback below.
}
if (!response.ok) {
const detail = payload && typeof payload === "object" && "detail" in payload
? String((payload as { detail?: unknown }).detail)
: fallback;
throw new E30ReviewContractError(detail);
}
return payload;
}
function validId(value: string, prefix: string): boolean {
return new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(value);
}
function generationQuery(engineeringGenerationId: string): string {
return `?engineering_generation_id=${encodeURIComponent(
engineeringGenerationId,
)}`;
}
export async function createOrResumeE30HumanReview(
resultId: string,
engineeringGenerationId: string,
reviewerId = "DC",
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
): Promise<E30HumanReviewDraft> {
if (
!validId(resultId, "e30-materialization")
|| !validId(engineeringGenerationId, "e30-engineering-generation")
) {
throw new E30ReviewContractError("Некорректная очередь проверки.");
}
const response = await (options.fetcher ?? fetch)(
`/api/v1/laboratory/e30/reviews/${resultId}/human-review`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
reviewer_id: reviewerId,
engineering_generation_id: engineeringGenerationId,
}),
signal: options.signal,
},
);
return parseE30HumanReviewDraft(
await responseJson(response, "Не удалось открыть проверку."),
);
}
export async function saveE30HumanReviewDecision(
resultId: string,
engineeringGenerationId: string,
draftId: string,
itemId: string,
request: {
expectedRevision: number;
idempotencyKey: string;
disposition: E30ExceptionDisposition;
notes: string | null;
},
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
): Promise<E30HumanReviewDraft> {
const response = await (options.fetcher ?? fetch)(
`/api/v1/laboratory/e30/reviews/${resultId}/human-review/${draftId}`
+ `/decisions/${itemId}${generationQuery(engineeringGenerationId)}`,
{
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
expected_revision: request.expectedRevision,
idempotency_key: request.idempotencyKey,
disposition: request.disposition,
notes: request.notes,
}),
signal: options.signal,
},
);
return parseE30HumanReviewDraft(
await responseJson(response, "Не удалось сохранить решение."),
);
}
export async function finalizeE30HumanReview(
resultId: string,
engineeringGenerationId: string,
draftId: string,
expectedRevision: number,
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
): Promise<E30HumanReviewFinalized> {
const response = await (options.fetcher ?? fetch)(
`/api/v1/laboratory/e30/reviews/${resultId}/human-review/${draftId}`
+ `/finalize${generationQuery(engineeringGenerationId)}`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
expected_revision: expectedRevision,
confirm_generation: true,
}),
signal: options.signal,
},
);
return parseE30HumanReviewFinalized(
await responseJson(response, "Не удалось зафиксировать проверку."),
);
}
@@ -0,0 +1,769 @@
export const E30_STRATA = [
"conflict",
"agree",
"camera-only",
"unknown",
"geometry-only",
] as const;
export type E30Stratum = (typeof E30_STRATA)[number];
export interface E30ReviewResult {
resultId: string;
createdAtUtc: string | null;
reviewPackId: string;
e29ResultId: string;
sourceSessionId: string;
itemCount: number;
stratumCounts: Record<E30Stratum, number>;
reasonTaxonomy: readonly string[];
projection: {
width: number;
height: number;
sourceId: string;
calibrationSlot: string;
};
cameraEvidenceAvailable: boolean;
humanReviewComplete: false;
labPublished: false;
access: "read-only";
}
export interface E30ReviewCatalog {
configured: boolean;
candidateTotal: number;
invalidTotal: number;
items: readonly E30ReviewResult[];
}
export interface E30ReviewSnapshot {
label: string | null;
geometryStatus: string;
geometryReason: string | null;
rangeM: number | null;
nearestRangeM: number | null;
bboxXyxy: readonly [number, number, number, number] | null;
}
export interface E30ReviewItem {
itemId: string;
sequence: number;
reviewKey: string;
stratum: E30Stratum;
rangeBucket: string;
frameIndex: number;
sourceFrameIndex: number;
sessionSeconds: number;
locatorKind: "semantic-observation" | "geometry-only-cluster";
snapshot: E30ReviewSnapshot;
materialization: {
framePointCount: number;
projectedPointCount: number;
projectionWidth: number;
projectionHeight: number;
candidatePointCount: number;
selectedPointCount: number;
rejectedCandidatePointCount: number;
selectedAndCandidateLossless: true;
freeSpaceValid: false;
humanReviewComplete: false;
detectorScore: number | null;
};
cameraFrameAvailable: boolean;
engineeringTriage: E30EngineeringTriage;
review: {
state: "unreviewed";
reasonCode: null;
notes: null;
};
access: "read-only";
}
export interface E30EngineeringTriage {
provenance: "deterministic-evidence-readiness/v1";
state: "ready-for-ai-review" | "blocked-camera-frame-unavailable";
attention: "standard" | "elevated";
signals: readonly string[];
semanticVerdict: null;
humanExceptionRequired: null;
}
export interface E30ReviewItems {
resultId: string;
stratum: E30Stratum;
items: readonly E30ReviewItem[];
total: number;
nextCursor: number | null;
reasonTaxonomy: readonly string[];
}
export interface E30ReviewItemDetail extends E30ReviewItem {
cameraFrame: {
available: true;
url: string;
sha256: string;
width: number;
height: number;
sourceFrameIndex: number;
exactSourceFrame: true;
} | null;
selected: {
sourceIndices: readonly number[];
pointsMapXyzM: readonly (readonly [number, number, number])[];
};
candidate: {
sourceIndices: readonly number[];
pointsMapXyzM: readonly (readonly [number, number, number])[];
};
projection: {
sourceIndices: readonly number[];
pointsMapXyzM: readonly (readonly [number, number, number])[];
pixelsXy: readonly (readonly [number, number])[];
depthM: readonly number[];
pointClass: readonly number[];
pointHeightM: readonly number[];
candidateMask: readonly number[];
selectedMask: readonly number[];
};
pose: {
positionMapXyzM: readonly [number, number, number];
orientationMapFromLidarXyzw: readonly [number, number, number, number];
};
}
export class E30ReviewContractError extends Error {
constructor(message: string) {
super(message);
this.name = "E30ReviewContractError";
}
}
type E30Fetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new E30ReviewContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function stringValue(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new E30ReviewContractError(`${label}: ожидалась строка.`);
}
return value;
}
function numberValue(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new E30ReviewContractError(`${label}: ожидалось конечное число.`);
}
return value;
}
function integerValue(value: unknown, label: string): number {
const parsed = numberValue(value, label);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new E30ReviewContractError(`${label}: ожидалось целое число.`);
}
return parsed;
}
function booleanValue(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new E30ReviewContractError(`${label}: ожидался boolean.`);
}
return value;
}
function falseValue(value: unknown, label: string): false {
if (value !== false) {
throw new E30ReviewContractError(`${label}: ожидалось false.`);
}
return false;
}
function trueValue(value: unknown, label: string): true {
if (value !== true) {
throw new E30ReviewContractError(`${label}: ожидалось true.`);
}
return true;
}
function nullValue(value: unknown, label: string): null {
if (value !== null) {
throw new E30ReviewContractError(`${label}: ожидалось null.`);
}
return null;
}
function readOnly(value: unknown, label: string): "read-only" {
if (value !== "read-only") {
throw new E30ReviewContractError(`${label}: ожидалось read-only.`);
}
return "read-only";
}
function stratumValue(value: unknown, label: string): E30Stratum {
if (
typeof value !== "string"
|| !E30_STRATA.includes(value as E30Stratum)
) {
throw new E30ReviewContractError(`${label}: неизвестная страта.`);
}
return value as E30Stratum;
}
function nullableNumber(value: unknown, label: string): number | null {
return value === null || value === undefined ? null : numberValue(value, label);
}
function nullableString(value: unknown, label: string): string | null {
return value === null || value === undefined ? null : stringValue(value, label);
}
function numberTuple<const N extends number>(
value: unknown,
length: N,
label: string,
): readonly number[] {
if (!Array.isArray(value) || value.length !== length) {
throw new E30ReviewContractError(`${label}: неверная размерность.`);
}
return value.map((item, index) => numberValue(item, `${label}[${index}]`));
}
function numberArray(value: unknown, label: string): readonly number[] {
if (!Array.isArray(value)) {
throw new E30ReviewContractError(`${label}: ожидался массив.`);
}
return value.map((item, index) => numberValue(item, `${label}[${index}]`));
}
function integerArray(value: unknown, label: string): readonly number[] {
if (!Array.isArray(value)) {
throw new E30ReviewContractError(`${label}: ожидался массив.`);
}
return value.map((item, index) => integerValue(item, `${label}[${index}]`));
}
function points(
value: unknown,
dimensions: 2 | 3,
label: string,
): readonly (readonly number[])[] {
if (!Array.isArray(value)) {
throw new E30ReviewContractError(`${label}: ожидался массив точек.`);
}
return value.map((item, index) => (
numberTuple(item, dimensions, `${label}[${index}]`)
));
}
function strings(value: unknown, label: string): readonly string[] {
if (!Array.isArray(value)) {
throw new E30ReviewContractError(`${label}: ожидался массив строк.`);
}
return value.map((item, index) => stringValue(item, `${label}[${index}]`));
}
function contentId(
value: unknown,
prefix: string,
label: string,
): string {
const parsed = stringValue(value, label);
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
throw new E30ReviewContractError(`${label}: некорректный content id.`);
}
return parsed;
}
function parseResult(value: unknown): E30ReviewResult {
const source = record(value, "E30 result");
const counts = record(source.stratum_counts, "E30 stratum_counts");
const projection = record(source.projection, "E30 projection");
const authority = record(source.authority, "E30 authority");
falseValue(authority.commands_enabled, "E30 authority.commands_enabled");
falseValue(
authority.navigation_or_safety_accepted,
"E30 authority.navigation_or_safety_accepted",
);
return {
resultId: contentId(
source.result_id,
"e30-materialization",
"E30 result_id",
),
createdAtUtc: source.created_at_utc === null
? null
: stringValue(source.created_at_utc, "E30 created_at_utc"),
reviewPackId: contentId(
source.review_pack_id,
"e30-review-pack",
"E30 review_pack_id",
),
e29ResultId: contentId(
source.e29_result_id,
"e29-camera-geometry",
"E30 e29_result_id",
),
sourceSessionId: stringValue(
source.source_session_id,
"E30 source_session_id",
),
itemCount: integerValue(source.item_count, "E30 item_count"),
stratumCounts: Object.fromEntries(E30_STRATA.map((stratum) => [
stratum,
integerValue(counts[stratum], `E30 stratum_counts.${stratum}`),
])) as Record<E30Stratum, number>,
reasonTaxonomy: strings(source.reason_taxonomy, "E30 reason_taxonomy"),
projection: {
width: integerValue(projection.width, "E30 projection.width"),
height: integerValue(projection.height, "E30 projection.height"),
sourceId: stringValue(projection.source_id, "E30 projection.source_id"),
calibrationSlot: stringValue(
projection.calibration_slot,
"E30 projection.calibration_slot",
),
},
cameraEvidenceAvailable: booleanValue(
source.camera_evidence_available,
"E30 camera_evidence_available",
),
humanReviewComplete: falseValue(
source.human_review_complete,
"E30 human_review_complete",
),
labPublished: falseValue(source.lab_published, "E30 lab_published"),
access: readOnly(source.access, "E30 access"),
};
}
function parseSnapshot(value: unknown): E30ReviewSnapshot {
const source = record(value, "E30 snapshot");
const rawBbox = source.bbox_xyxy;
return {
label: nullableString(source.label ?? source.semantic_class, "E30 snapshot.label"),
geometryStatus: stringValue(
source.geometry_status,
"E30 snapshot.geometry_status",
),
geometryReason: nullableString(
source.geometry_reason,
"E30 snapshot.geometry_reason",
),
rangeM: nullableNumber(source.range_m, "E30 snapshot.range_m"),
nearestRangeM: nullableNumber(
source.nearest_range_m,
"E30 snapshot.nearest_range_m",
),
bboxXyxy: rawBbox === null || rawBbox === undefined
? null
: numberTuple(rawBbox, 4, "E30 snapshot.bbox_xyxy") as
readonly [number, number, number, number],
};
}
function parseItem(value: unknown): E30ReviewItem {
const source = record(value, "E30 item");
const locator = record(source.locator, "E30 item.locator");
const materialized = record(
source.materialization,
"E30 item.materialization",
);
const review = record(source.review, "E30 item.review");
const triage = record(
source.engineering_triage,
"E30 item.engineering_triage",
);
const locatorKind = stringValue(locator.kind, "E30 item.locator.kind");
if (
locatorKind !== "semantic-observation"
&& locatorKind !== "geometry-only-cluster"
) {
throw new E30ReviewContractError("E30 item.locator.kind не поддерживается.");
}
if (review.state !== "unreviewed") {
throw new E30ReviewContractError("E30 item уже изменён вне review gate.");
}
if (
triage.provenance !== "deterministic-evidence-readiness/v1"
|| (
triage.state !== "ready-for-ai-review"
&& triage.state !== "blocked-camera-frame-unavailable"
)
|| (triage.attention !== "standard" && triage.attention !== "elevated")
) {
throw new E30ReviewContractError("E30 engineering triage не поддерживается.");
}
return {
itemId: contentId(
source.item_id,
"e30-review-item",
"E30 item.item_id",
),
sequence: integerValue(source.sequence, "E30 item.sequence"),
reviewKey: stringValue(source.review_key, "E30 item.review_key"),
stratum: stratumValue(source.stratum, "E30 item.stratum"),
rangeBucket: stringValue(source.range_bucket, "E30 item.range_bucket"),
frameIndex: integerValue(source.frame_index, "E30 item.frame_index"),
sourceFrameIndex: integerValue(
source.source_frame_index,
"E30 item.source_frame_index",
),
sessionSeconds: numberValue(
source.session_seconds,
"E30 item.session_seconds",
),
locatorKind,
snapshot: parseSnapshot(source.snapshot),
materialization: {
framePointCount: integerValue(
materialized.frame_point_count,
"E30 materialization.frame_point_count",
),
projectedPointCount: integerValue(
materialized.projected_point_count,
"E30 materialization.projected_point_count",
),
projectionWidth: integerValue(
materialized.projection_width,
"E30 materialization.projection_width",
),
projectionHeight: integerValue(
materialized.projection_height,
"E30 materialization.projection_height",
),
candidatePointCount: integerValue(
materialized.candidate_point_count,
"E30 materialization.candidate_point_count",
),
selectedPointCount: integerValue(
materialized.selected_point_count,
"E30 materialization.selected_point_count",
),
rejectedCandidatePointCount: integerValue(
materialized.rejected_candidate_point_count,
"E30 materialization.rejected_candidate_point_count",
),
selectedAndCandidateLossless: trueValue(
materialized.selected_and_candidate_lossless,
"E30 materialization.selected_and_candidate_lossless",
),
freeSpaceValid: falseValue(
materialized.free_space_valid,
"E30 materialization.free_space_valid",
),
humanReviewComplete: falseValue(
materialized.human_review_complete,
"E30 materialization.human_review_complete",
),
detectorScore: nullableNumber(
materialized.detector_score,
"E30 materialization.detector_score",
),
},
cameraFrameAvailable: booleanValue(
source.camera_frame_available,
"E30 item.camera_frame_available",
),
engineeringTriage: {
provenance: "deterministic-evidence-readiness/v1",
state: triage.state,
attention: triage.attention,
signals: strings(triage.signals, "E30 engineering_triage.signals"),
semanticVerdict: nullValue(
triage.semantic_verdict,
"E30 engineering_triage.semantic_verdict",
),
humanExceptionRequired: nullValue(
triage.human_exception_required,
"E30 engineering_triage.human_exception_required",
),
},
review: {
state: "unreviewed",
reasonCode: nullValue(review.reason_code, "E30 review.reason_code"),
notes: nullValue(review.notes, "E30 review.notes"),
},
access: readOnly(source.access, "E30 item.access"),
};
}
export function parseE30ReviewItem(value: unknown): E30ReviewItem {
return parseItem(value);
}
export function parseE30ReviewCatalog(value: unknown): E30ReviewCatalog {
const source = record(value, "E30 catalog");
if (source.schema_version !== "missioncore.laboratory-e30-catalog/v1") {
throw new E30ReviewContractError("E30 catalog schema не поддерживается.");
}
if (!Array.isArray(source.items)) {
throw new E30ReviewContractError("E30 catalog.items имеет неверный формат.");
}
readOnly(source.access, "E30 catalog.access");
return {
configured: booleanValue(source.configured, "E30 catalog.configured"),
candidateTotal: integerValue(
source.candidate_total,
"E30 catalog.candidate_total",
),
invalidTotal: integerValue(source.invalid_total, "E30 catalog.invalid_total"),
items: source.items.map(parseResult),
};
}
export function parseE30ReviewItems(value: unknown): E30ReviewItems {
const source = record(value, "E30 items");
if (
source.schema_version !== "missioncore.laboratory-e30-items/v1"
|| !Array.isArray(source.items)
) {
throw new E30ReviewContractError("E30 items schema не поддерживается.");
}
readOnly(source.access, "E30 items.access");
return {
resultId: stringValue(source.result_id, "E30 items.result_id"),
stratum: stratumValue(source.stratum, "E30 items.stratum"),
items: source.items.map(parseItem),
total: integerValue(source.total, "E30 items.total"),
nextCursor: source.next_cursor === null
? null
: integerValue(source.next_cursor, "E30 items.next_cursor"),
reasonTaxonomy: strings(source.reason_taxonomy, "E30 items.reason_taxonomy"),
};
}
export function parseE30ReviewItemDetail(value: unknown): E30ReviewItemDetail {
const response = record(value, "E30 item detail response");
if (response.schema_version !== "missioncore.laboratory-e30-item-detail/v1") {
throw new E30ReviewContractError("E30 detail schema не поддерживается.");
}
readOnly(response.access, "E30 detail access");
const source = record(response.item, "E30 item detail");
const item = parseItem(source);
const selected = record(source.selected, "E30 selected");
const candidate = record(source.candidate, "E30 candidate");
const projection = record(source.projection, "E30 projection detail");
const pose = record(source.pose, "E30 pose");
const cameraFrame = source.camera_frame === null
? null
: record(source.camera_frame, "E30 camera_frame");
const projectedLength = integerArray(
projection.source_indices,
"E30 projection.source_indices",
).length;
const projectionCollections = [
projection.points_map_xyz_m,
projection.pixels_xy,
projection.depth_m,
projection.point_class,
projection.point_height_m,
projection.candidate_mask,
projection.selected_mask,
];
if (
projectionCollections.some(
(collection) => !Array.isArray(collection) || collection.length !== projectedLength,
)
) {
throw new E30ReviewContractError("E30 projection arrays имеют разную длину.");
}
const candidateMask = integerArray(
projection.candidate_mask,
"E30 projection.candidate_mask",
);
const selectedMask = integerArray(
projection.selected_mask,
"E30 projection.selected_mask",
);
if (
candidateMask.some((itemValue) => itemValue !== 0 && itemValue !== 1)
|| selectedMask.some((itemValue) => itemValue !== 0 && itemValue !== 1)
) {
throw new E30ReviewContractError("E30 projection mask должна быть бинарной.");
}
const selectedSourceIndices = integerArray(
selected.source_indices,
"E30 selected.source_indices",
);
const selectedPoints = points(
selected.points_map_xyz_m,
3,
"E30 selected.points_map_xyz_m",
) as readonly (readonly [number, number, number])[];
const candidateSourceIndices = integerArray(
candidate.source_indices,
"E30 candidate.source_indices",
);
const candidatePoints = points(
candidate.points_map_xyz_m,
3,
"E30 candidate.points_map_xyz_m",
) as readonly (readonly [number, number, number])[];
if (
selectedSourceIndices.length !== selectedPoints.length
|| candidateSourceIndices.length !== candidatePoints.length
|| selectedSourceIndices.length !== item.materialization.selectedPointCount
|| candidateSourceIndices.length !== item.materialization.candidatePointCount
) {
throw new E30ReviewContractError("E30 selected/candidate arrays расходятся.");
}
return {
...item,
cameraFrame: cameraFrame === null
? null
: {
available: trueValue(
cameraFrame.available,
"E30 camera_frame.available",
),
url: stringValue(cameraFrame.url, "E30 camera_frame.url"),
sha256: stringValue(cameraFrame.sha256, "E30 camera_frame.sha256"),
width: integerValue(cameraFrame.width, "E30 camera_frame.width"),
height: integerValue(cameraFrame.height, "E30 camera_frame.height"),
sourceFrameIndex: integerValue(
cameraFrame.source_frame_index,
"E30 camera_frame.source_frame_index",
),
exactSourceFrame: trueValue(
cameraFrame.exact_source_frame,
"E30 camera_frame.exact_source_frame",
),
},
selected: {
sourceIndices: selectedSourceIndices,
pointsMapXyzM: selectedPoints,
},
candidate: {
sourceIndices: candidateSourceIndices,
pointsMapXyzM: candidatePoints,
},
projection: {
sourceIndices: integerArray(
projection.source_indices,
"E30 projection.source_indices",
),
pointsMapXyzM: points(
projection.points_map_xyz_m,
3,
"E30 projection.points_map_xyz_m",
) as readonly (readonly [number, number, number])[],
pixelsXy: points(
projection.pixels_xy,
2,
"E30 projection.pixels_xy",
) as readonly (readonly [number, number])[],
depthM: numberArray(projection.depth_m, "E30 projection.depth_m"),
pointClass: integerArray(
projection.point_class,
"E30 projection.point_class",
),
pointHeightM: numberArray(
projection.point_height_m,
"E30 projection.point_height_m",
),
candidateMask,
selectedMask,
},
pose: {
positionMapXyzM: numberTuple(
pose.position_map_xyz_m,
3,
"E30 pose.position_map_xyz_m",
) as readonly [number, number, number],
orientationMapFromLidarXyzw: numberTuple(
pose.orientation_map_from_lidar_xyzw,
4,
"E30 pose.orientation_map_from_lidar_xyzw",
) as readonly [number, number, number, number],
},
};
}
async function responseJson(response: Response, fallback: string): Promise<unknown> {
let payload: unknown = null;
try {
payload = await response.json();
} catch {
// Preserve the status-aware fallback below.
}
if (!response.ok) {
const detail = payload && typeof payload === "object" && "detail" in payload
? String((payload as { detail?: unknown }).detail)
: fallback;
throw new E30ReviewContractError(detail);
}
return payload;
}
function validResultId(value: string): boolean {
return /^e30-materialization-[a-f0-9]{64}$/.test(value);
}
function validItemId(value: string): boolean {
return /^e30-review-item-[a-f0-9]{64}$/.test(value);
}
export async function fetchE30ReviewCatalog(
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
): Promise<E30ReviewCatalog> {
const fetcher = options.fetcher ?? fetch;
const response = await fetcher("/api/v1/laboratory/e30/reviews?limit=1", {
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
});
return parseE30ReviewCatalog(
await responseJson(response, "Не удалось получить LAB E30."),
);
}
export async function fetchE30ReviewItems(
resultId: string,
stratum: E30Stratum,
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
): Promise<E30ReviewItems> {
if (!validResultId(resultId) || !E30_STRATA.includes(stratum)) {
throw new E30ReviewContractError("Некорректный E30 review query.");
}
const fetcher = options.fetcher ?? fetch;
const response = await fetcher(
`/api/v1/laboratory/e30/reviews/${resultId}/items?stratum=${stratum}&limit=128&cursor=0`,
{
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
},
);
return parseE30ReviewItems(
await responseJson(response, "Не удалось получить выборку LAB E30."),
);
}
export async function fetchE30ReviewItemDetail(
resultId: string,
itemId: string,
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
): Promise<E30ReviewItemDetail> {
if (!validResultId(resultId) || !validItemId(itemId)) {
throw new E30ReviewContractError("Некорректный E30 review item id.");
}
const fetcher = options.fetcher ?? fetch;
const response = await fetcher(
`/api/v1/laboratory/e30/reviews/${resultId}/items/${itemId}`,
{
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
},
);
return parseE30ReviewItemDetail(
await responseJson(response, "Не удалось получить доказательство LAB E30."),
);
}
@@ -0,0 +1,480 @@
export type E34TemporalState = "current" | "held" | "expired";
export type E34OccupancyState = "occupied" | "unknown";
export type E34OwnerKind = "camera-track" | "geometry-cluster";
export type E34Point3 = readonly [number, number, number];
export interface E34TemporalHistoryPoint {
frameIndex: number;
sessionSeconds: number;
centroidMapXyzM: E34Point3;
}
export interface E34TemporalComponent {
temporalId: number;
state: E34TemporalState;
occupancyState: E34OccupancyState;
ownerKind: E34OwnerKind;
centroidMapXyzM: E34Point3;
lastObservedAgeSeconds: number;
associationReason: string;
history: readonly E34TemporalHistoryPoint[];
}
export interface E34TemporalReviewFrame {
frameIndex: number;
sessionSeconds: number;
sourceAvailable: boolean;
layerState: "current" | "held" | "unknown";
counts: {
current: number;
held: number;
expired: number;
};
cellCentersMapXyzM: readonly E34Point3[];
components: readonly E34TemporalComponent[];
}
export interface E34TemporalLayerResult {
resultId: string;
createdAtUtc: string | null;
sourceSessionId: string;
status: "accepted-bounded-occupied-unknown-temporal-layer";
e32ResultId: string;
e33ResultId: string;
profileId: string;
pipelineId: string;
coordinateFrame: "map";
configuration: {
voxelSizeM: number;
occupiedTtlSeconds: number;
maximumActiveComponents: number;
maximumCellsPerComponent: number;
geometryMaximumGapSeconds: number;
geometryMaximumCentroidDistanceM: number;
};
metrics: {
sourceFrames: number;
processedFrames: number;
framesWithCurrentLayer: number;
heldOnlyFrames: number;
unknownEmptyFrames: number;
createdComponents: number;
cameraCreatedComponents: number;
geometryCreatedComponents: number;
geometryReassociatedComponents: number;
exactCameraAssociations: number;
geometrySpatialReassociations: number;
heldPublications: number;
expiredComponents: number;
peakActiveComponents: number;
maximumObservedSpanSeconds: number;
continuityFraction: number;
geometryOnlyReassociationFraction: number;
e32CurrentPointRows: number;
e34ConsumedCurrentPointRows: number;
currentCellRows: number;
heldCellRows: number;
storedCellRows: number;
freeCellRows: number;
peakCellsPerComponent: number;
maximumHeldAgeSeconds: number;
maximumExpiryDelaySeconds: number;
maximumReplayMaterializationDelaySeconds: number;
mapFrameJumpCandidates: number;
frameProcessingP95Ms: number;
buildElapsedMs: number;
};
reviewFrames: readonly E34TemporalReviewFrame[];
access: "read-only";
}
type LaboratoryFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
class E34TemporalLayerContractError extends Error {
constructor(message: string) {
super(message);
this.name = "E34TemporalLayerContractError";
}
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new E34TemporalLayerContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function list(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) {
throw new E34TemporalLayerContractError(`${label}: ожидался массив.`);
}
return value;
}
function stringValue(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new E34TemporalLayerContractError(`${label}: ожидалась строка.`);
}
return value;
}
function optionalString(value: unknown, label: string): string | null {
return value === null ? null : stringValue(value, label);
}
function finiteNumber(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new E34TemporalLayerContractError(`${label}: ожидалось число.`);
}
return value;
}
function numberValue(value: unknown, label: string): number {
const parsed = finiteNumber(value, label);
if (parsed < 0) {
throw new E34TemporalLayerContractError(`${label}: число отрицательно.`);
}
return parsed;
}
function integerValue(value: unknown, label: string): number {
const parsed = numberValue(value, label);
if (!Number.isSafeInteger(parsed)) {
throw new E34TemporalLayerContractError(`${label}: ожидалось целое число.`);
}
return parsed;
}
function booleanValue(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new E34TemporalLayerContractError(`${label}: ожидался boolean.`);
}
return value;
}
function exactString<T extends string>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) {
throw new E34TemporalLayerContractError(`${label}: неверное значение.`);
}
return expected;
}
function oneOf<T extends string>(
value: unknown,
expected: readonly T[],
label: string,
): T {
if (typeof value !== "string" || !expected.includes(value as T)) {
throw new E34TemporalLayerContractError(`${label}: неверное значение.`);
}
return value as T;
}
function contentId(value: unknown, prefix: string, label: string): string {
const parsed = stringValue(value, label);
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
throw new E34TemporalLayerContractError(`${label}: неверный content id.`);
}
return parsed;
}
function point(value: unknown, label: string): E34Point3 {
const coordinates = list(value, label);
if (coordinates.length !== 3) {
throw new E34TemporalLayerContractError(`${label}: ожидались XYZ.`);
}
return [
finiteNumber(coordinates[0], `${label}[0]`),
finiteNumber(coordinates[1], `${label}[1]`),
finiteNumber(coordinates[2], `${label}[2]`),
];
}
function parseHistory(value: unknown, label: string): E34TemporalHistoryPoint {
const item = record(value, label);
return {
frameIndex: integerValue(item.frame_index, `${label}.frame_index`),
sessionSeconds: numberValue(
item.session_seconds,
`${label}.session_seconds`,
),
centroidMapXyzM: point(
item.centroid_map_xyz_m,
`${label}.centroid_map_xyz_m`,
),
};
}
function parseComponent(value: unknown, label: string): E34TemporalComponent {
const item = record(value, label);
return {
temporalId: integerValue(item.temporal_id, `${label}.temporal_id`),
state: oneOf(
item.state,
["current", "held", "expired"] as const,
`${label}.state`,
),
occupancyState: oneOf(
item.occupancy_state,
["occupied", "unknown"] as const,
`${label}.occupancy_state`,
),
ownerKind: oneOf(
item.owner_kind,
["camera-track", "geometry-cluster"] as const,
`${label}.owner_kind`,
),
centroidMapXyzM: point(
item.centroid_map_xyz_m,
`${label}.centroid_map_xyz_m`,
),
lastObservedAgeSeconds: numberValue(
item.last_observed_age_seconds,
`${label}.last_observed_age_seconds`,
),
associationReason: stringValue(
item.association_reason,
`${label}.association_reason`,
),
history: list(item.history_tail, `${label}.history_tail`).map(
(entry, index) => parseHistory(entry, `${label}.history_tail[${index}]`),
),
};
}
function parseReviewFrame(
value: unknown,
label: string,
): E34TemporalReviewFrame {
const item = record(value, label);
const counts = record(item.counts, `${label}.counts`);
return {
frameIndex: integerValue(item.frame_index, `${label}.frame_index`),
sessionSeconds: numberValue(
item.session_seconds,
`${label}.session_seconds`,
),
sourceAvailable: booleanValue(
item.source_available,
`${label}.source_available`,
),
layerState: oneOf(
item.layer_state,
["current", "held", "unknown"] as const,
`${label}.layer_state`,
),
counts: {
current: integerValue(counts.current, `${label}.counts.current`),
held: integerValue(counts.held, `${label}.counts.held`),
expired: integerValue(counts.expired, `${label}.counts.expired`),
},
cellCentersMapXyzM: list(
item.cell_centers_map_xyz_m,
`${label}.cell_centers_map_xyz_m`,
).map((entry, index) => point(
entry,
`${label}.cell_centers_map_xyz_m[${index}]`,
)),
components: list(item.components, `${label}.components`).map(
(entry, index) => parseComponent(
entry,
`${label}.components[${index}]`,
),
),
};
}
function diagnosticAuthority(value: unknown, label: string): void {
const authority = record(value, label);
if (
authority.commands_enabled !== false
|| authority.navigation_or_safety_accepted !== false
) {
throw new E34TemporalLayerContractError(
`${label}: запрещённые полномочия.`,
);
}
}
function metricRecord(
value: Record<string, unknown>,
): E34TemporalLayerResult["metrics"] {
const metric = (key: string) => numberValue(
value[key],
`E34.metrics.${key}`,
);
const integer = (key: string) => integerValue(
value[key],
`E34.metrics.${key}`,
);
return {
sourceFrames: integer("source_frames"),
processedFrames: integer("processed_frames"),
framesWithCurrentLayer: integer("frames_with_current_layer"),
heldOnlyFrames: integer("held_only_frames"),
unknownEmptyFrames: integer("unknown_empty_frames"),
createdComponents: integer("created_components"),
cameraCreatedComponents: integer("camera_created_components"),
geometryCreatedComponents: integer("geometry_created_components"),
geometryReassociatedComponents: integer("geometry_reassociated_components"),
exactCameraAssociations: integer("exact_camera_associations"),
geometrySpatialReassociations: integer("geometry_spatial_reassociations"),
heldPublications: integer("held_publications"),
expiredComponents: integer("expired_components"),
peakActiveComponents: integer("peak_active_components"),
maximumObservedSpanSeconds: metric("maximum_observed_span_seconds"),
continuityFraction: metric("continuity_fraction"),
geometryOnlyReassociationFraction: metric(
"geometry_only_reassociation_fraction",
),
e32CurrentPointRows: integer("e32_current_point_rows"),
e34ConsumedCurrentPointRows: integer("e34_consumed_current_point_rows"),
currentCellRows: integer("current_cell_rows"),
heldCellRows: integer("held_cell_rows"),
storedCellRows: integer("stored_cell_rows"),
freeCellRows: integer("free_cell_rows"),
peakCellsPerComponent: integer("peak_cells_per_component"),
maximumHeldAgeSeconds: metric("maximum_held_age_seconds"),
maximumExpiryDelaySeconds: metric("maximum_expiry_delay_seconds"),
maximumReplayMaterializationDelaySeconds: metric(
"maximum_replay_materialization_delay_seconds",
),
mapFrameJumpCandidates: integer("map_frame_jump_candidates"),
frameProcessingP95Ms: metric("frame_processing_p95_ms"),
buildElapsedMs: metric("build_elapsed_ms"),
};
}
function parseResult(value: unknown): E34TemporalLayerResult {
const item = record(value, "E34");
const configuration = record(item.configuration, "E34.configuration");
const review = record(item.review, "E34.review");
const acceptance = record(item.acceptance, "E34.acceptance");
diagnosticAuthority(item.authority, "E34.authority");
if (acceptance.accepted !== true) {
throw new E34TemporalLayerContractError("E34.acceptance: результат отклонён.");
}
exactString(
review.schema_version,
"missioncore.e34-temporal-review-timeline/v1",
"E34.review.schema_version",
);
return {
resultId: contentId(
item.result_id,
"e34-temporal-occupied",
"E34.result_id",
),
createdAtUtc: optionalString(item.created_at_utc, "E34.created_at_utc"),
sourceSessionId: stringValue(
item.source_session_id,
"E34.source_session_id",
),
status: exactString(
item.status,
"accepted-bounded-occupied-unknown-temporal-layer",
"E34.status",
),
e32ResultId: contentId(
item.e32_result_id,
"e32-track-geometry",
"E34.e32_result_id",
),
e33ResultId: contentId(
item.e33_result_id,
"e33-worker-shadow",
"E34.e33_result_id",
),
profileId: stringValue(item.profile_id, "E34.profile_id"),
pipelineId: stringValue(item.pipeline_id, "E34.pipeline_id"),
coordinateFrame: exactString(
item.coordinate_frame,
"map",
"E34.coordinate_frame",
),
configuration: {
voxelSizeM: numberValue(
configuration.voxel_size_m,
"E34.configuration.voxel_size_m",
),
occupiedTtlSeconds: numberValue(
configuration.occupied_ttl_seconds,
"E34.configuration.occupied_ttl_seconds",
),
maximumActiveComponents: integerValue(
configuration.maximum_active_components,
"E34.configuration.maximum_active_components",
),
maximumCellsPerComponent: integerValue(
configuration.maximum_cells_per_component,
"E34.configuration.maximum_cells_per_component",
),
geometryMaximumGapSeconds: numberValue(
configuration.geometry_maximum_gap_seconds,
"E34.configuration.geometry_maximum_gap_seconds",
),
geometryMaximumCentroidDistanceM: numberValue(
configuration.geometry_maximum_centroid_distance_m,
"E34.configuration.geometry_maximum_centroid_distance_m",
),
},
metrics: metricRecord(record(item.metrics, "E34.metrics")),
reviewFrames: list(review.frames, "E34.review.frames").map(
(entry, index) => parseReviewFrame(
entry,
`E34.review.frames[${index}]`,
),
),
access: exactString(item.access, "read-only", "E34.access"),
};
}
export async function fetchE34TemporalLayerResult({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<E34TemporalLayerResult | null> {
const response = await fetcher(
"/api/v1/laboratory/e34/results?limit=1",
{
method: "GET",
headers: { Accept: "application/json" },
signal,
},
);
if (!response.ok) {
throw new E34TemporalLayerContractError(
`Каталог LAB E34 недоступен: HTTP ${response.status}.`,
);
}
const catalog = record(await response.json(), "Каталог LAB E34");
exactString(
catalog.schema_version,
"missioncore.laboratory-advanced-catalog/v1",
"Каталог LAB E34.schema_version",
);
booleanValue(catalog.configured, "Каталог LAB E34.configured");
integerValue(catalog.candidate_total, "Каталог LAB E34.candidate_total");
integerValue(catalog.invalid_total, "Каталог LAB E34.invalid_total");
exactString(
catalog.access,
"read-only",
"Каталог LAB E34.access",
);
const items = list(catalog.items, "Каталог LAB E34.items");
if (items.length > 1) {
throw new E34TemporalLayerContractError(
"Каталог LAB E34: нарушен limit=1.",
);
}
return items.length ? parseResult(items[0]) : null;
}
@@ -0,0 +1,578 @@
import type {
E34OccupancyState,
E34OwnerKind,
E34Point3,
E34TemporalState,
} from "./e34TemporalLayer";
export type E35DegradationKind =
| "camera-loss"
| "lidar-loss"
| "pose-staleness"
| "delayed-frames"
| "bounded-drop"
| "timing-offset";
export type E35FaultPhase = "before" | "during" | "after";
export interface E35ScenarioMetrics {
scenarioId: E35DegradationKind;
kind: E35DegradationKind;
frameStart: number;
frameEnd: number;
framesProcessed: number;
injectedFrames: number;
droppedFrames: number;
hiddenSuccessFrames: number;
falseFreeRows: number;
semanticClaimsDuringCameraLoss: number;
metricRowsDuringLidarOrPoseLoss: number;
agreeClaimsDuringTimingOffset: number;
lateResultsReintroduced: number;
maximumCurrentComponentsDuringFault: number;
maximumHeldComponentsDuringFault: number;
expiredComponentsDuringFault: number;
recoveryFrameIndex: number;
recoverySeconds: number;
}
export interface E35TemporalComponent {
temporalId: number;
state: E34TemporalState;
occupancyState: E34OccupancyState;
ownerKind: E34OwnerKind;
semanticLabels: readonly string[];
centroidMapXyzM: E34Point3;
lastObservedAgeSeconds: number;
}
export interface E35RecoveryReviewFrame {
scenarioId: E35DegradationKind;
kind: E35DegradationKind;
frameIndex: number;
sessionSeconds: number;
faultPhase: E35FaultPhase;
action: string;
channels: Readonly<Record<string, string>>;
inputPointRows: number;
transformedPointRows: number;
layerState: "current" | "held" | "unknown";
counts: {
current: number;
held: number;
expired: number;
};
components: readonly E35TemporalComponent[];
cellCentersMapXyzM: readonly E34Point3[];
}
export interface E35RecoveryReviewScenario {
scenarioId: E35DegradationKind;
kind: E35DegradationKind;
frameStart: number;
frameEnd: number;
frames: readonly E35RecoveryReviewFrame[];
}
export interface E35DegradationRecoveryResult {
resultId: string;
createdAtUtc: string | null;
sourceSessionId: string;
status: "accepted-deterministic-degradation-recovery";
e32ResultId: string;
e33ResultId: string;
e34ResultId: string;
profileId: string;
pipelineId: string;
coordinateFrame: "map";
configuration: {
maximumRecoverySeconds: number;
scenarioCount: number;
};
metrics: {
sourceFrames: number;
variantCount: number;
variantFrameOutcomes: number;
injectionRecords: number;
variantFrameProcessingP95Ms: number;
buildElapsedMs: number;
};
scenarios: readonly E35ScenarioMetrics[];
reviewScenarios: readonly E35RecoveryReviewScenario[];
access: "read-only";
}
type LaboratoryFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
class E35DegradationRecoveryContractError extends Error {
constructor(message: string) {
super(message);
this.name = "E35DegradationRecoveryContractError";
}
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new E35DegradationRecoveryContractError(
`${label}: ожидался объект.`,
);
}
return value as Record<string, unknown>;
}
function list(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) {
throw new E35DegradationRecoveryContractError(
`${label}: ожидался массив.`,
);
}
return value;
}
function stringValue(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new E35DegradationRecoveryContractError(
`${label}: ожидалась строка.`,
);
}
return value;
}
function optionalString(value: unknown, label: string): string | null {
return value === null ? null : stringValue(value, label);
}
function numberValue(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new E35DegradationRecoveryContractError(
`${label}: ожидалось неотрицательное число.`,
);
}
return value;
}
function integerValue(value: unknown, label: string): number {
const parsed = numberValue(value, label);
if (!Number.isSafeInteger(parsed)) {
throw new E35DegradationRecoveryContractError(
`${label}: ожидалось целое число.`,
);
}
return parsed;
}
function exactString<T extends string>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) {
throw new E35DegradationRecoveryContractError(
`${label}: неверное значение.`,
);
}
return expected;
}
function oneOf<T extends string>(
value: unknown,
expected: readonly T[],
label: string,
): T {
if (typeof value !== "string" || !expected.includes(value as T)) {
throw new E35DegradationRecoveryContractError(
`${label}: неверное значение.`,
);
}
return value as T;
}
function contentId(value: unknown, prefix: string, label: string): string {
const parsed = stringValue(value, label);
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
throw new E35DegradationRecoveryContractError(
`${label}: неверный content id.`,
);
}
return parsed;
}
function point(value: unknown, label: string): E34Point3 {
const coordinates = list(value, label);
if (coordinates.length !== 3) {
throw new E35DegradationRecoveryContractError(
`${label}: ожидались XYZ.`,
);
}
const result = coordinates.map(
(coordinate, index) => {
if (typeof coordinate !== "number" || !Number.isFinite(coordinate)) {
throw new E35DegradationRecoveryContractError(
`${label}[${index}]: ожидалось число.`,
);
}
return coordinate;
},
);
return [result[0]!, result[1]!, result[2]!];
}
const DEGRADATION_KINDS = [
"camera-loss",
"lidar-loss",
"pose-staleness",
"delayed-frames",
"bounded-drop",
"timing-offset",
] as const;
function degradationKind(
value: unknown,
label: string,
): E35DegradationKind {
return oneOf(value, DEGRADATION_KINDS, label);
}
function parseComponent(
value: unknown,
label: string,
): E35TemporalComponent {
const item = record(value, label);
return {
temporalId: integerValue(item.temporal_id, `${label}.temporal_id`),
state: oneOf(
item.state,
["current", "held", "expired"] as const,
`${label}.state`,
),
occupancyState: oneOf(
item.occupancy_state,
["occupied", "unknown"] as const,
`${label}.occupancy_state`,
),
ownerKind: oneOf(
item.owner_kind,
["camera-track", "geometry-cluster"] as const,
`${label}.owner_kind`,
),
semanticLabels: list(
item.semantic_labels,
`${label}.semantic_labels`,
).map((entry, index) => stringValue(
entry,
`${label}.semantic_labels[${index}]`,
)),
centroidMapXyzM: point(
item.centroid_map_xyz_m,
`${label}.centroid_map_xyz_m`,
),
lastObservedAgeSeconds: numberValue(
item.last_observed_age_seconds,
`${label}.last_observed_age_seconds`,
),
};
}
function parseReviewFrame(
value: unknown,
label: string,
): E35RecoveryReviewFrame {
const item = record(value, label);
const counts = record(item.counts, `${label}.counts`);
const channels = record(item.channels, `${label}.channels`);
return {
scenarioId: degradationKind(
item.scenario_id,
`${label}.scenario_id`,
),
kind: degradationKind(item.kind, `${label}.kind`),
frameIndex: integerValue(item.frame_index, `${label}.frame_index`),
sessionSeconds: numberValue(
item.session_seconds,
`${label}.session_seconds`,
),
faultPhase: oneOf(
item.fault_phase,
["before", "during", "after"] as const,
`${label}.fault_phase`,
),
action: stringValue(item.action, `${label}.action`),
channels: Object.fromEntries(
Object.entries(channels).map(([key, entry]) => [
key,
stringValue(entry, `${label}.channels.${key}`),
]),
),
inputPointRows: integerValue(
item.input_point_rows,
`${label}.input_point_rows`,
),
transformedPointRows: integerValue(
item.transformed_point_rows,
`${label}.transformed_point_rows`,
),
layerState: oneOf(
item.layer_state,
["current", "held", "unknown"] as const,
`${label}.layer_state`,
),
counts: {
current: integerValue(counts.current, `${label}.counts.current`),
held: integerValue(counts.held, `${label}.counts.held`),
expired: integerValue(counts.expired, `${label}.counts.expired`),
},
components: list(item.components, `${label}.components`).map(
(entry, index) => parseComponent(
entry,
`${label}.components[${index}]`,
),
),
cellCentersMapXyzM: list(
item.cell_centers_map_xyz_m,
`${label}.cell_centers_map_xyz_m`,
).map((entry, index) => point(
entry,
`${label}.cell_centers_map_xyz_m[${index}]`,
)),
};
}
function parseReviewScenario(
value: unknown,
label: string,
): E35RecoveryReviewScenario {
const item = record(value, label);
const scenario = record(item.scenario, `${label}.scenario`);
return {
scenarioId: degradationKind(
scenario.scenario_id,
`${label}.scenario.scenario_id`,
),
kind: degradationKind(
scenario.kind,
`${label}.scenario.kind`,
),
frameStart: integerValue(
scenario.frame_start,
`${label}.scenario.frame_start`,
),
frameEnd: integerValue(
scenario.frame_end,
`${label}.scenario.frame_end`,
),
frames: list(item.frames, `${label}.frames`).map(
(entry, index) => parseReviewFrame(
entry,
`${label}.frames[${index}]`,
),
),
};
}
function parseScenarioMetrics(
value: unknown,
label: string,
): E35ScenarioMetrics {
const item = record(value, label);
const integer = (key: string) => integerValue(item[key], `${label}.${key}`);
return {
scenarioId: degradationKind(item.scenario_id, `${label}.scenario_id`),
kind: degradationKind(item.kind, `${label}.kind`),
frameStart: integer("frame_start"),
frameEnd: integer("frame_end"),
framesProcessed: integer("frames_processed"),
injectedFrames: integer("injected_frames"),
droppedFrames: integer("dropped_frames"),
hiddenSuccessFrames: integer("hidden_success_frames"),
falseFreeRows: integer("false_free_rows"),
semanticClaimsDuringCameraLoss: integer(
"semantic_claims_during_camera_loss",
),
metricRowsDuringLidarOrPoseLoss: integer(
"metric_rows_during_lidar_or_pose_loss",
),
agreeClaimsDuringTimingOffset: integer(
"agree_claims_during_timing_offset",
),
lateResultsReintroduced: integer("late_results_reintroduced"),
maximumCurrentComponentsDuringFault: integer(
"maximum_current_components_during_fault",
),
maximumHeldComponentsDuringFault: integer(
"maximum_held_components_during_fault",
),
expiredComponentsDuringFault: integer(
"expired_components_during_fault",
),
recoveryFrameIndex: integer("recovery_frame_index"),
recoverySeconds: numberValue(
item.recovery_seconds,
`${label}.recovery_seconds`,
),
};
}
function diagnosticAuthority(value: unknown, label: string): void {
const authority = record(value, label);
if (
authority.commands_enabled !== false
|| authority.navigation_or_safety_accepted !== false
) {
throw new E35DegradationRecoveryContractError(
`${label}: запрещённые полномочия.`,
);
}
}
function parseResult(value: unknown): E35DegradationRecoveryResult {
const item = record(value, "E35");
const configuration = record(item.configuration, "E35.configuration");
const metrics = record(item.metrics, "E35.metrics");
const review = record(item.review, "E35.review");
const acceptance = record(item.acceptance, "E35.acceptance");
diagnosticAuthority(item.authority, "E35.authority");
if (acceptance.accepted !== true) {
throw new E35DegradationRecoveryContractError(
"E35.acceptance: результат отклонён.",
);
}
exactString(
review.schema_version,
"missioncore.e35-recovery-review/v1",
"E35.review.schema_version",
);
return {
resultId: contentId(
item.result_id,
"e35-degradation-recovery",
"E35.result_id",
),
createdAtUtc: optionalString(item.created_at_utc, "E35.created_at_utc"),
sourceSessionId: stringValue(
item.source_session_id,
"E35.source_session_id",
),
status: exactString(
item.status,
"accepted-deterministic-degradation-recovery",
"E35.status",
),
e32ResultId: contentId(
item.e32_result_id,
"e32-track-geometry",
"E35.e32_result_id",
),
e33ResultId: contentId(
item.e33_result_id,
"e33-worker-shadow",
"E35.e33_result_id",
),
e34ResultId: contentId(
item.e34_result_id,
"e34-temporal-occupied",
"E35.e34_result_id",
),
profileId: stringValue(item.profile_id, "E35.profile_id"),
pipelineId: stringValue(item.pipeline_id, "E35.pipeline_id"),
coordinateFrame: exactString(
item.coordinate_frame,
"map",
"E35.coordinate_frame",
),
configuration: {
maximumRecoverySeconds: numberValue(
configuration.maximum_recovery_seconds,
"E35.configuration.maximum_recovery_seconds",
),
scenarioCount: integerValue(
configuration.scenario_count,
"E35.configuration.scenario_count",
),
},
metrics: {
sourceFrames: integerValue(
metrics.source_frames,
"E35.metrics.source_frames",
),
variantCount: integerValue(
metrics.variant_count,
"E35.metrics.variant_count",
),
variantFrameOutcomes: integerValue(
metrics.variant_frame_outcomes,
"E35.metrics.variant_frame_outcomes",
),
injectionRecords: integerValue(
metrics.injection_records,
"E35.metrics.injection_records",
),
variantFrameProcessingP95Ms: numberValue(
metrics.variant_frame_processing_p95_ms,
"E35.metrics.variant_frame_processing_p95_ms",
),
buildElapsedMs: numberValue(
metrics.build_elapsed_ms,
"E35.metrics.build_elapsed_ms",
),
},
scenarios: list(item.scenarios, "E35.scenarios").map(
(entry, index) => parseScenarioMetrics(
entry,
`E35.scenarios[${index}]`,
),
),
reviewScenarios: list(review.scenarios, "E35.review.scenarios").map(
(entry, index) => parseReviewScenario(
entry,
`E35.review.scenarios[${index}]`,
),
),
access: exactString(item.access, "read-only", "E35.access"),
};
}
export async function fetchE35DegradationRecoveryResult({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<E35DegradationRecoveryResult | null> {
const response = await fetcher(
"/api/v1/laboratory/e35/results?limit=1",
{
method: "GET",
headers: { Accept: "application/json" },
signal,
},
);
if (!response.ok) {
throw new E35DegradationRecoveryContractError(
`Каталог LAB E35 недоступен: HTTP ${response.status}.`,
);
}
const catalog = record(await response.json(), "Каталог LAB E35");
exactString(
catalog.schema_version,
"missioncore.laboratory-advanced-catalog/v1",
"Каталог LAB E35.schema_version",
);
if (typeof catalog.configured !== "boolean") {
throw new E35DegradationRecoveryContractError(
"Каталог LAB E35.configured: ожидался boolean.",
);
}
integerValue(catalog.candidate_total, "Каталог LAB E35.candidate_total");
integerValue(catalog.invalid_total, "Каталог LAB E35.invalid_total");
exactString(catalog.access, "read-only", "Каталог LAB E35.access");
const items = list(catalog.items, "Каталог LAB E35.items");
if (items.length > 1) {
throw new E35DegradationRecoveryContractError(
"Каталог LAB E35: нарушен limit=1.",
);
}
return items.length ? parseResult(items[0]) : null;
}
@@ -0,0 +1,543 @@
export const E40_CASE_DIMENSIONS = [
"presence",
"geometry_association",
"freshness",
] as const;
export type E40CaseDimension = (typeof E40_CASE_DIMENSIONS)[number];
export type E40CaseSeverity = "high" | "medium" | "standard";
export type E40CaseStratum =
| "agree"
| "camera-only"
| "conflict"
| "geometry-only"
| "unknown";
export type E40PredictionBasis =
| "camera-only-softmax"
| "fixed-stratum-policy";
export interface E40CaseState {
presence: "background-or-noise" | "object-present" | "occupied-environment";
geometryAssociation:
| "independent-occupied"
| "insufficient-support"
| "object-associated"
| "rejected-nonobject"
| "unknown";
freshness: "current" | "stale" | "unavailable";
}
export interface E40ErrorCase {
itemId: string;
sequence: number;
sourceFrameIndex: number;
sourceStratum: E40CaseStratum;
severity: E40CaseSeverity;
presenceConfidence: number;
predictionBasis: E40PredictionBasis;
reference: E40CaseState;
prediction: E40CaseState;
mismatchedDimensions: readonly E40CaseDimension[];
access: "read-only";
}
export interface E40CaseCatalog {
resultId: string;
materializationId: string;
items: readonly E40ErrorCase[];
total: number;
truncated: boolean;
access: "read-only";
}
/**
* Immutable v1 storage vocabulary. Product UI interprets these legacy values
* only as support/challenge of the E37 engineering label; neither value
* establishes physical ground truth or authorizes an operational model.
*/
export type E40OperatorVerdict = "confirmed-error" | "rejected-error";
export interface E40OperatorDecision {
itemId: string;
verdict: E40OperatorVerdict;
revision: number;
idempotencyKey: string;
decidedAtUtc: string;
eventId: string;
}
export interface E40OperatorReview {
reviewId: string;
resultId: string;
materializationId: string;
caseCatalogSha256: string;
itemSetSha256: string;
itemCount: number;
reviewerId: string;
revision: number;
decisions: readonly E40OperatorDecision[];
reviewedItemCount: number;
remainingItemCount: number;
updatedAtUtc: string | null;
access: "review-write";
}
export class E40CaseReviewContractError extends Error {
constructor(message: string) {
super(message);
this.name = "E40CaseReviewContractError";
}
}
type LaboratoryFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new E40CaseReviewContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function stringValue(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new E40CaseReviewContractError(`${label}: ожидалась строка.`);
}
return value;
}
function contentId(value: unknown, prefix: string, label: string): string {
const parsed = stringValue(value, label);
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
throw new E40CaseReviewContractError(`${label}: неверный content id.`);
}
return parsed;
}
function sha256Value(value: unknown, label: string): string {
const parsed = stringValue(value, label);
if (!/^[a-f0-9]{64}$/.test(parsed)) {
throw new E40CaseReviewContractError(`${label}: неверный SHA-256.`);
}
return parsed;
}
function integerValue(value: unknown, label: string): number {
if (
typeof value !== "number"
|| !Number.isSafeInteger(value)
|| value < 0
) {
throw new E40CaseReviewContractError(`${label}: ожидалось целое число.`);
}
return value;
}
function nullableString(value: unknown, label: string): string | null {
if (value === null) return null;
return stringValue(value, label);
}
function probability(value: unknown, label: string): number {
if (
typeof value !== "number"
|| !Number.isFinite(value)
|| value < 0
|| value > 1
) {
throw new E40CaseReviewContractError(`${label}: ожидалась вероятность.`);
}
return value;
}
function exactString<T extends string>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) {
throw new E40CaseReviewContractError(`${label}: неверное значение.`);
}
return expected;
}
function enumValue<T extends string>(
value: unknown,
allowed: readonly T[],
label: string,
): T {
if (typeof value !== "string" || !allowed.includes(value as T)) {
throw new E40CaseReviewContractError(`${label}: значение не поддерживается.`);
}
return value as T;
}
function parseState(value: unknown, label: string): E40CaseState {
const source = record(value, label);
return {
presence: enumValue(
source.presence,
[
"background-or-noise",
"object-present",
"occupied-environment",
],
`${label}.presence`,
),
geometryAssociation: enumValue(
source.geometry_association,
[
"independent-occupied",
"insufficient-support",
"object-associated",
"rejected-nonobject",
"unknown",
],
`${label}.geometry_association`,
),
freshness: enumValue(
source.freshness,
["current", "stale", "unavailable"],
`${label}.freshness`,
),
};
}
function parseCase(value: unknown, index: number): E40ErrorCase {
const label = `E40 case[${index}]`;
const source = record(value, label);
if (!Array.isArray(source.mismatched_dimensions)) {
throw new E40CaseReviewContractError(
`${label}.mismatched_dimensions: ожидался массив.`,
);
}
const mismatchedDimensions = source.mismatched_dimensions.map(
(dimension, dimensionIndex) => enumValue(
dimension,
E40_CASE_DIMENSIONS,
`${label}.mismatched_dimensions[${dimensionIndex}]`,
),
);
if (!mismatchedDimensions.length) {
throw new E40CaseReviewContractError(`${label}: кейс не содержит ошибки.`);
}
return {
itemId: contentId(source.item_id, "e30-review-item", `${label}.item_id`),
sequence: integerValue(source.sequence, `${label}.sequence`),
sourceFrameIndex: integerValue(
source.source_frame_index,
`${label}.source_frame_index`,
),
sourceStratum: enumValue(
source.source_stratum,
["agree", "camera-only", "conflict", "geometry-only", "unknown"],
`${label}.source_stratum`,
),
severity: enumValue(
source.severity,
["high", "medium", "standard"],
`${label}.severity`,
),
presenceConfidence: probability(
source.presence_confidence,
`${label}.presence_confidence`,
),
predictionBasis: enumValue(
source.prediction_basis,
["camera-only-softmax", "fixed-stratum-policy"],
`${label}.prediction_basis`,
),
reference: parseState(source.reference, `${label}.reference`),
prediction: parseState(source.prediction, `${label}.prediction`),
mismatchedDimensions,
access: exactString(source.access, "read-only", `${label}.access`),
};
}
export function parseE40CaseCatalog(payload: unknown): E40CaseCatalog {
const source = record(payload, "E40 case catalog");
exactString(
source.schema_version,
"missioncore.laboratory-e40-case-catalog/v1",
"E40 case catalog.schema_version",
);
if (!Array.isArray(source.items) || source.items.length > 64) {
throw new E40CaseReviewContractError(
"E40 case catalog.items: нарушена граница.",
);
}
return {
resultId: contentId(
source.result_id,
"e40-perception-product-gate",
"E40 case catalog.result_id",
),
materializationId: contentId(
source.materialization_id,
"e30-materialization",
"E40 case catalog.materialization_id",
),
items: source.items.map(parseCase),
total: integerValue(source.total, "E40 case catalog.total"),
truncated: typeof source.truncated === "boolean"
? source.truncated
: (() => {
throw new E40CaseReviewContractError(
"E40 case catalog.truncated: ожидался boolean.",
);
})(),
access: exactString(
source.access,
"read-only",
"E40 case catalog.access",
),
};
}
function parseOperatorDecision(
value: unknown,
index: number,
): E40OperatorDecision {
const label = `E40 operator decision[${index}]`;
const source = record(value, label);
return {
itemId: contentId(source.item_id, "e30-review-item", `${label}.item_id`),
verdict: enumValue(
source.verdict,
["confirmed-error", "rejected-error"],
`${label}.verdict`,
),
revision: integerValue(source.revision, `${label}.revision`),
idempotencyKey: stringValue(
source.idempotency_key,
`${label}.idempotency_key`,
),
decidedAtUtc: stringValue(
source.decided_at_utc,
`${label}.decided_at_utc`,
),
eventId: contentId(
source.event_id,
"e40-operator-verdict",
`${label}.event_id`,
),
};
}
export function parseE40OperatorReview(
payload: unknown,
): E40OperatorReview {
const source = record(payload, "E40 operator review");
exactString(
source.schema_version,
"missioncore.e40-operator-review/v1",
"E40 operator review.schema_version",
);
exactString(
source.protocol,
"sealed-error-adjudication/v1",
"E40 operator review.protocol",
);
const binding = record(source.source, "E40 operator review.source");
if (!Array.isArray(source.decisions) || source.decisions.length > 64) {
throw new E40CaseReviewContractError(
"E40 operator review.decisions: нарушена граница.",
);
}
const decisions = source.decisions.map(parseOperatorDecision);
const reviewedItemCount = integerValue(
source.reviewed_item_count,
"E40 operator review.reviewed_item_count",
);
const remainingItemCount = integerValue(
source.remaining_item_count,
"E40 operator review.remaining_item_count",
);
const itemCount = integerValue(
binding.item_count,
"E40 operator review.source.item_count",
);
if (
reviewedItemCount !== decisions.length
|| reviewedItemCount + remainingItemCount !== itemCount
|| new Set(decisions.map((decision) => decision.itemId)).size
!== decisions.length
) {
throw new E40CaseReviewContractError(
"E40 operator review: счётчики решений несовместимы.",
);
}
return {
reviewId: contentId(
source.review_id,
"e40-operator-review",
"E40 operator review.review_id",
),
resultId: contentId(
binding.result_id,
"e40-perception-product-gate",
"E40 operator review.source.result_id",
),
materializationId: contentId(
binding.materialization_id,
"e30-materialization",
"E40 operator review.source.materialization_id",
),
caseCatalogSha256: sha256Value(
binding.case_catalog_sha256,
"E40 operator review.source.case_catalog_sha256",
),
itemSetSha256: sha256Value(
binding.item_set_sha256,
"E40 operator review.source.item_set_sha256",
),
itemCount,
reviewerId: stringValue(
source.reviewer_id,
"E40 operator review.reviewer_id",
),
revision: integerValue(
source.revision,
"E40 operator review.revision",
),
decisions,
reviewedItemCount,
remainingItemCount,
updatedAtUtc: nullableString(
source.updated_at_utc,
"E40 operator review.updated_at_utc",
),
access: exactString(
source.access,
"review-write",
"E40 operator review.access",
),
};
}
export async function fetchE40CaseCatalog(
resultId: string,
{
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {},
): Promise<E40CaseCatalog> {
const response = await fetcher(
`/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}/cases?limit=48`,
{
method: "GET",
headers: { Accept: "application/json" },
signal,
},
);
if (!response.ok) {
throw new E40CaseReviewContractError(
`Case-review E40 недоступен: HTTP ${response.status}.`,
);
}
const catalog = parseE40CaseCatalog(await response.json());
if (catalog.resultId !== resultId) {
throw new E40CaseReviewContractError(
"Case-review E40 вернул другой результат.",
);
}
return catalog;
}
export async function fetchE40OperatorReview(
resultId: string,
{
reviewerId = "DC",
fetcher = fetch,
signal,
}: {
reviewerId?: string;
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {},
): Promise<E40OperatorReview> {
const response = await fetcher(
`/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}`
+ `/operator-review?reviewer_id=${encodeURIComponent(reviewerId)}`,
{
method: "GET",
headers: { Accept: "application/json" },
signal,
},
);
if (!response.ok) {
throw new E40CaseReviewContractError(
`Операторская проверка E40 недоступна: HTTP ${response.status}.`,
);
}
const review = parseE40OperatorReview(await response.json());
if (review.resultId !== resultId || review.reviewerId !== reviewerId) {
throw new E40CaseReviewContractError(
"Операторская проверка E40 вернула другой источник.",
);
}
return review;
}
export async function saveE40OperatorVerdict(
resultId: string,
itemId: string,
{
reviewerId = "DC",
expectedRevision,
idempotencyKey,
verdict,
fetcher = fetch,
signal,
}: {
reviewerId?: string;
expectedRevision: number;
idempotencyKey: string;
verdict: E40OperatorVerdict;
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
},
): Promise<E40OperatorReview> {
const response = await fetcher(
`/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}`
+ `/operator-review/decisions/${encodeURIComponent(itemId)}`,
{
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
reviewer_id: reviewerId,
expected_revision: expectedRevision,
idempotency_key: idempotencyKey,
verdict,
}),
signal,
},
);
if (!response.ok) {
throw new E40CaseReviewContractError(
`Решение E40 не сохранено: HTTP ${response.status}.`,
);
}
const review = parseE40OperatorReview(await response.json());
if (
review.resultId !== resultId
|| review.reviewerId !== reviewerId
|| !review.decisions.some(
(decision) => decision.itemId === itemId
&& decision.verdict === verdict,
)
) {
throw new E40CaseReviewContractError(
"Решение E40 не подтверждено сервером.",
);
}
return review;
}
@@ -0,0 +1,458 @@
export interface E40DevelopmentDimensionMetric {
correct: number;
incorrect: number;
total: number;
accuracy: number;
target: number;
passed: boolean;
}
export interface E40DimensionMetric extends E40DevelopmentDimensionMetric {
byStratum: Readonly<Record<string, {
correct: number;
incorrect: number;
total: number;
accuracy: number;
}>>;
confusion: readonly {
reference: string;
prediction: string;
count: number;
}[];
}
export interface E40DevelopmentProtocol {
items: number;
foldSizes: Readonly<Record<string, number>>;
passed: boolean;
dimensions: {
presence: E40DevelopmentDimensionMetric;
geometryAssociation: E40DevelopmentDimensionMetric;
freshness: E40DevelopmentDimensionMetric;
};
}
export interface E40PerceptionProductGateResult {
resultId: string;
createdAtUtc: string | null;
sourceSessionId: string;
sourceDisplayName: string;
status: "measured-leakage-resistant-product-gate";
profileId: string;
workerNode: string;
qualityGatePassed: boolean;
developmentCrossValidation: {
strategy: string;
seed: string;
folds: number;
items: number;
validationLabelsUsed: false;
passed: boolean;
protocols: {
contiguousSourceTime: E40DevelopmentProtocol;
wholeTrackOrSceneWindow: E40DevelopmentProtocol;
};
};
metrics: {
developmentItems: number;
validationItems: number;
terminalOutcomes: number;
accountingFraction: number;
falseFreeClaims: number;
highSeverityFailures: number;
dimensions: {
presence: E40DimensionMetric;
geometryAssociation: E40DimensionMetric;
freshness: E40DimensionMetric;
};
};
blockingChecks: readonly string[];
method: {
summary: string;
selection: string;
dimensionProjection: string;
};
limitations: readonly string[];
access: "read-only";
}
export class E40ProductGateContractError extends Error {
constructor(message: string) {
super(message);
this.name = "E40ProductGateContractError";
}
}
type LaboratoryFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new E40ProductGateContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function stringValue(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new E40ProductGateContractError(`${label}: ожидалась строка.`);
}
return value;
}
function optionalString(value: unknown, label: string): string | null {
return value === null ? null : stringValue(value, label);
}
function numberValue(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new E40ProductGateContractError(`${label}: ожидалось число.`);
}
return value;
}
function integerValue(value: unknown, label: string): number {
const parsed = numberValue(value, label);
if (!Number.isSafeInteger(parsed)) {
throw new E40ProductGateContractError(`${label}: ожидалось целое число.`);
}
return parsed;
}
function booleanValue(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new E40ProductGateContractError(`${label}: ожидался boolean.`);
}
return value;
}
function falseValue(value: unknown, label: string): false {
if (value !== false) {
throw new E40ProductGateContractError(`${label}: ожидалось false.`);
}
return false;
}
function exactString<T extends string>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) {
throw new E40ProductGateContractError(`${label}: неверное значение.`);
}
return expected;
}
function strings(value: unknown, label: string): readonly string[] {
if (!Array.isArray(value)) {
throw new E40ProductGateContractError(`${label}: ожидался массив.`);
}
return value.map((item, index) => stringValue(item, `${label}[${index}]`));
}
function numberRecord(
value: unknown,
label: string,
): Readonly<Record<string, number>> {
const source = record(value, label);
return Object.fromEntries(
Object.entries(source).map(([key, item]) => [
key,
integerValue(item, `${label}.${key}`),
]),
);
}
function contentId(value: unknown, prefix: string, label: string): string {
const parsed = stringValue(value, label);
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
throw new E40ProductGateContractError(`${label}: неверный content id.`);
}
return parsed;
}
function diagnosticAuthority(value: unknown, label: string): void {
const authority = record(value, label);
if (
authority.commands_enabled !== false
|| authority.navigation_or_safety_accepted !== false
) {
throw new E40ProductGateContractError(`${label}: запрещённые полномочия.`);
}
}
function parseDevelopmentDimension(
value: unknown,
label: string,
): E40DevelopmentDimensionMetric {
const dimension = record(value, label);
return {
correct: integerValue(dimension.correct, `${label}.correct`),
incorrect: integerValue(dimension.incorrect, `${label}.incorrect`),
total: integerValue(dimension.total, `${label}.total`),
accuracy: numberValue(dimension.accuracy, `${label}.accuracy`),
target: numberValue(dimension.target, `${label}.target`),
passed: booleanValue(dimension.passed, `${label}.passed`),
};
}
function parseDimension(value: unknown, label: string): E40DimensionMetric {
const dimension = record(value, label);
const byStratum = record(dimension.by_stratum, `${label}.by_stratum`);
const confusion = dimension.confusion;
if (!Array.isArray(confusion)) {
throw new E40ProductGateContractError(
`${label}.confusion: ожидался массив.`,
);
}
return {
...parseDevelopmentDimension(value, label),
byStratum: Object.fromEntries(
Object.entries(byStratum).map(([stratum, raw]) => {
const item = record(raw, `${label}.by_stratum.${stratum}`);
return [stratum, {
correct: integerValue(
item.correct,
`${label}.by_stratum.${stratum}.correct`,
),
incorrect: integerValue(
item.incorrect,
`${label}.by_stratum.${stratum}.incorrect`,
),
total: integerValue(
item.total,
`${label}.by_stratum.${stratum}.total`,
),
accuracy: numberValue(
item.accuracy,
`${label}.by_stratum.${stratum}.accuracy`,
),
}];
}),
),
confusion: confusion.map((raw, index) => {
const item = record(raw, `${label}.confusion[${index}]`);
return {
reference: stringValue(
item.reference,
`${label}.confusion[${index}].reference`,
),
prediction: stringValue(
item.prediction,
`${label}.confusion[${index}].prediction`,
),
count: integerValue(
item.count,
`${label}.confusion[${index}].count`,
),
};
}),
};
}
function parseDevelopmentProtocol(
value: unknown,
label: string,
): E40DevelopmentProtocol {
const protocol = record(value, label);
const dimensions = record(protocol.dimensions, `${label}.dimensions`);
return {
items: integerValue(protocol.items, `${label}.items`),
foldSizes: numberRecord(protocol.fold_sizes, `${label}.fold_sizes`),
passed: booleanValue(protocol.passed, `${label}.passed`),
dimensions: {
presence: parseDevelopmentDimension(
dimensions.presence,
`${label}.dimensions.presence`,
),
geometryAssociation: parseDevelopmentDimension(
dimensions.geometry_association,
`${label}.dimensions.geometry_association`,
),
freshness: parseDevelopmentDimension(
dimensions.freshness,
`${label}.dimensions.freshness`,
),
},
};
}
function parseResult(value: unknown): E40PerceptionProductGateResult {
const item = record(value, "E40");
const metrics = record(item.metrics, "E40.metrics");
const dimensions = record(metrics.dimensions, "E40.metrics.dimensions");
const development = record(
item.development_cross_validation,
"E40.development_cross_validation",
);
const protocols = record(
development.protocols,
"E40.development_cross_validation.protocols",
);
const method = record(item.method, "E40.method");
diagnosticAuthority(item.authority, "E40.authority");
return {
resultId: contentId(
item.result_id,
"e40-perception-product-gate",
"E40.result_id",
),
createdAtUtc: optionalString(item.created_at_utc, "E40.created_at_utc"),
sourceSessionId: stringValue(
item.source_session_id,
"E40.source_session_id",
),
sourceDisplayName: stringValue(
item.source_display_name,
"E40.source_display_name",
),
status: exactString(
item.status,
"measured-leakage-resistant-product-gate",
"E40.status",
),
profileId: stringValue(item.profile_id, "E40.profile_id"),
workerNode: stringValue(item.worker_node, "E40.worker_node"),
qualityGatePassed: booleanValue(
item.quality_gate_passed,
"E40.quality_gate_passed",
),
developmentCrossValidation: {
strategy: stringValue(
development.strategy,
"E40.development_cross_validation.strategy",
),
seed: stringValue(
development.seed,
"E40.development_cross_validation.seed",
),
folds: integerValue(
development.folds,
"E40.development_cross_validation.folds",
),
items: integerValue(
development.items,
"E40.development_cross_validation.items",
),
validationLabelsUsed: falseValue(
development.validation_labels_used,
"E40.development_cross_validation.validation_labels_used",
),
passed: booleanValue(
development.passed,
"E40.development_cross_validation.passed",
),
protocols: {
contiguousSourceTime: parseDevelopmentProtocol(
protocols["contiguous-source-time-five-fold"],
"E40.development_cross_validation.protocols.contiguous-source-time-five-fold",
),
wholeTrackOrSceneWindow: parseDevelopmentProtocol(
protocols["whole-track-or-scene-window-five-fold"],
"E40.development_cross_validation.protocols.whole-track-or-scene-window-five-fold",
),
},
},
metrics: {
developmentItems: integerValue(
metrics.development_items,
"E40.metrics.development_items",
),
validationItems: integerValue(
metrics.validation_items,
"E40.metrics.validation_items",
),
terminalOutcomes: integerValue(
metrics.terminal_outcomes,
"E40.metrics.terminal_outcomes",
),
accountingFraction: numberValue(
metrics.accounting_fraction,
"E40.metrics.accounting_fraction",
),
falseFreeClaims: integerValue(
metrics.false_free_claims,
"E40.metrics.false_free_claims",
),
highSeverityFailures: integerValue(
metrics.high_severity_failures,
"E40.metrics.high_severity_failures",
),
dimensions: {
presence: parseDimension(
dimensions.presence,
"E40.metrics.dimensions.presence",
),
geometryAssociation: parseDimension(
dimensions.geometry_association,
"E40.metrics.dimensions.geometry_association",
),
freshness: parseDimension(
dimensions.freshness,
"E40.metrics.dimensions.freshness",
),
},
},
blockingChecks: strings(item.blocking_checks, "E40.blocking_checks"),
method: {
summary: stringValue(method.summary, "E40.method.summary"),
selection: stringValue(method.selection, "E40.method.selection"),
dimensionProjection: stringValue(
method.dimension_projection,
"E40.method.dimension_projection",
),
},
limitations: strings(item.limitations, "E40.limitations"),
access: exactString(item.access, "read-only", "E40.access"),
};
}
function parseCatalog(payload: unknown): E40PerceptionProductGateResult | null {
const catalog = record(payload, "Каталог LAB E40");
exactString(
catalog.schema_version,
"missioncore.laboratory-advanced-catalog/v1",
"Каталог LAB E40.schema_version",
);
booleanValue(catalog.configured, "Каталог LAB E40.configured");
integerValue(catalog.candidate_total, "Каталог LAB E40.candidate_total");
integerValue(catalog.invalid_total, "Каталог LAB E40.invalid_total");
exactString(catalog.access, "read-only", "Каталог LAB E40.access");
if (!Array.isArray(catalog.items)) {
throw new E40ProductGateContractError(
"Каталог LAB E40.items: ожидался массив.",
);
}
if (catalog.items.length > 1) {
throw new E40ProductGateContractError(
"Каталог LAB E40.items: нарушен limit=1.",
);
}
return catalog.items.length ? parseResult(catalog.items[0]) : null;
}
export async function fetchE40ProductGateResult({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<E40PerceptionProductGateResult | null> {
const response = await fetcher(
"/api/v1/laboratory/e40/results?limit=1",
{
method: "GET",
headers: { Accept: "application/json" },
signal,
},
);
if (!response.ok) {
throw new E40ProductGateContractError(
`Каталог LAB E40 недоступен: HTTP ${response.status}.`,
);
}
return parseCatalog(await response.json());
}
@@ -0,0 +1,243 @@
export interface E46BlindReviewSubmissionSummary {
resultId: string;
reviewerId: string;
annotationSessionId: string;
state: "completed-e48-review-input-not-truth";
frameCount: number;
objectCount: number;
}
export interface E46BlindReviewResult {
resultId: string;
createdAtUtc: string;
status: "prepared-awaiting-independent-human-review";
sourceSessionId: string;
cameraSourceId: "sensor.camera.right";
metrics: {
frameCount: 32;
anchorCount: 16;
temporalFrameCount: 16;
temporalGroupCount: 4;
reviewSessionCount: number;
completedSubmissionCount: number;
requiredSubmissionCount: 2;
};
submissions: readonly E46BlindReviewSubmissionSummary[];
decision: {
collectionReady: true;
twoIndependentReviewsComplete: boolean;
adjudicationReady: boolean;
e48Sealed: false;
l35Open: false;
nextAction: string;
};
access: "review-collection-read-only-summary";
}
type LaboratoryFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
class E46BlindReviewContractError extends Error {}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new E46BlindReviewContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) {
throw new E46BlindReviewContractError(`${label}: ожидался массив.`);
}
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new E46BlindReviewContractError(`${label}: ожидалась строка.`);
}
return value;
}
function integer(value: unknown, label: string): number {
if (!Number.isInteger(value) || Number(value) < 0) {
throw new E46BlindReviewContractError(`${label}: ожидалось целое число.`);
}
return Number(value);
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new E46BlindReviewContractError(`${label}: ожидался boolean.`);
}
return value;
}
function exact<T extends string | boolean | number>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) {
throw new E46BlindReviewContractError(`${label}: нарушен контракт.`);
}
return expected;
}
function parseSubmission(value: unknown): E46BlindReviewSubmissionSummary {
const item = object(value, "E46 submission");
const resultId = string(item.result_id, "E46 submission id");
if (!/^e46-lab-review-submission-[a-f0-9]{64}$/.test(resultId)) {
throw new E46BlindReviewContractError("E46 submission: нарушена identity.");
}
return {
resultId,
reviewerId: string(item.reviewer_id, "E46 reviewer"),
annotationSessionId: string(item.annotation_session_id, "E46 session"),
state: exact(
item.state,
"completed-e48-review-input-not-truth",
"E46 submission state",
),
frameCount: integer(item.frame_count, "E46 submitted frames"),
objectCount: integer(item.object_count, "E46 submitted objects"),
};
}
export async function fetchE46BlindReviewResult({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<E46BlindReviewResult | null> {
const response = await fetcher("/api/v1/laboratory/e46/results?limit=1", {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) {
throw new E46BlindReviewContractError(`E46 LAB недоступен: HTTP ${response.status}.`);
}
const catalog = object(await response.json(), "E46 catalog");
exact(
catalog.schema_version,
"missioncore.e46-detector-truth-island-catalog/v1",
"E46 catalog schema",
);
const items = array(catalog.items, "E46 results");
if (!items.length) return null;
if (items.length !== 1) {
throw new E46BlindReviewContractError("E46 catalog: нарушен размер.");
}
const item = object(items[0], "E46 result");
const resultId = string(item.result_id, "E46 result id");
if (!/^e46-detector-truth-island-[a-f0-9]{64}$/.test(resultId)) {
throw new E46BlindReviewContractError("E46 result: нарушена identity.");
}
const metrics = object(item.metrics, "E46 metrics");
const decision = object(item.decision, "E46 decision");
const blindness = object(item.blindness, "E46 blindness");
const authority = object(item.authority, "E46 authority");
const submissions = array(item.submissions, "E46 submissions").map(parseSubmission);
const reviewSessions = array(item.review_sessions, "E46 review sessions");
const completedSubmissionCount = integer(
metrics.completed_submission_count,
"E46 completed submissions",
);
if (
completedSubmissionCount !== submissions.length
|| completedSubmissionCount > 2
|| integer(metrics.review_session_count, "E46 session count") !== reviewSessions.length
|| reviewSessions.length > 2
|| submissions.some((submission) => submission.frameCount !== 32)
) {
throw new E46BlindReviewContractError("E46 review collection: нарушен размер.");
}
for (const key of (
[
"candidate_identity_included",
"model_prelabels_included",
"model_predictions_included",
"model_scores_included",
] as const
)) exact(blindness[key], false, `E46 blindness ${key}`);
for (const key of (
[
"ground_truth",
"independent_truth",
"metric_grade_reference",
"candidate_accepted",
"commands_enabled",
"navigation_or_safety_accepted",
] as const
)) exact(authority[key], false, `E46 authority ${key}`);
const twoComplete = boolean(
decision.two_independent_reviews_complete,
"E46 two review state",
);
const adjudicationReady = boolean(
decision.adjudication_ready,
"E46 adjudication state",
);
if (twoComplete !== (completedSubmissionCount === 2) || adjudicationReady !== twoComplete) {
throw new E46BlindReviewContractError("E46 decision: нарушен review gate.");
}
return {
resultId,
createdAtUtc: string(item.created_at_utc, "E46 created time"),
status: exact(
item.status,
"prepared-awaiting-independent-human-review",
"E46 status",
),
sourceSessionId: string(item.source_session_id, "E46 source session"),
cameraSourceId: exact(
item.camera_source_id,
"sensor.camera.right",
"E46 camera source",
),
metrics: {
frameCount: exact(metrics.frame_count, 32, "E46 frame count"),
anchorCount: exact(metrics.anchor_count, 16, "E46 anchors"),
temporalFrameCount: exact(
metrics.temporal_frame_count,
16,
"E46 temporal frames",
),
temporalGroupCount: exact(
metrics.temporal_group_count,
4,
"E46 temporal groups",
),
reviewSessionCount: integer(metrics.review_session_count, "E46 sessions"),
completedSubmissionCount,
requiredSubmissionCount: exact(
metrics.required_submission_count,
2,
"E46 required submissions",
),
},
submissions,
decision: {
collectionReady: exact(
decision.collection_ready,
true,
"E46 collection ready",
),
twoIndependentReviewsComplete: twoComplete,
adjudicationReady,
e48Sealed: exact(decision.e48_sealed, false, "E48 seal state"),
l35Open: exact(decision.l35_open, false, "L3.5 state"),
nextAction: string(decision.next_action, "E46 next action"),
},
access: exact(
item.access,
"review-collection-read-only-summary",
"E46 access",
),
};
}
@@ -0,0 +1,269 @@
export interface E46AObject {
objectId: string;
category: string;
displayCategory: string;
boxXyxy: readonly [number, number, number, number];
occluded: boolean;
truncated: boolean;
}
export interface E46ACase {
resultId: string;
truthIslandSequence: number;
imageId: number;
frameIndex: number;
groupId: string;
sessionSeconds: number;
sourceImageSha256: string;
objects: readonly E46AObject[];
objectCount: number;
cameraUrl: string;
groundTruth: false;
independentReview: false;
}
export interface E46AAiEngineeringPreannotationResult {
resultId: string;
createdAtUtc: string;
sourceSessionId: "RAVNOVES00";
cameraSourceId: "sensor.camera.right";
sourceE46ResultId: string;
metrics: {
frameCount: 32;
reviewedFrameCount: 32;
sourceObjectCount: number;
objectCount: number;
customClassRelabelCount: number;
deletedFalseBoxCount: number;
geometrySnappedObjectCount: number;
sourceGeometryRetainedObjectCount: number;
categoryCorrectedObjectCount: number;
hardNegativeFrameCount: number;
independentReviewSubmissionCount: 0;
classCounts: Readonly<Record<string, number>>;
};
taxonomy: {
classes: readonly string[];
customClasses: readonly ["laptop", "stroller"];
unresolvedClassCount: 0;
};
decision: {
preannotationAvailable: true;
visualReviewComplete: true;
independentReviewCountAffected: false;
e48TruthSealOpen: false;
l35AcceptanceOpen: false;
nextAction: string;
};
limitations: readonly string[];
groundTruth: false;
access: "read-only-ai-engineering-preannotation-not-truth";
}
type LaboratoryFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
class E46AContractError extends Error {}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new E46AContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) throw new E46AContractError(`${label}: ожидался массив.`);
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new E46AContractError(`${label}: ожидалась строка.`);
}
return value;
}
function number(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new E46AContractError(`${label}: ожидалось число.`);
}
return value;
}
function integer(value: unknown, label: string): number {
const parsed = number(value, label);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new E46AContractError(`${label}: ожидалось целое число.`);
}
return parsed;
}
function optionalInteger(value: unknown, fallback: number, label: string): number {
return value === undefined ? fallback : integer(value, label);
}
function exact<T extends string | boolean | number>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) throw new E46AContractError(`${label}: нарушен контракт.`);
return expected;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new E46AContractError(`${label}: ожидался boolean.`);
}
return value;
}
function sha256(value: unknown, label: string): string {
const parsed = string(value, label);
if (!/^[a-f0-9]{64}$/.test(parsed)) throw new E46AContractError(`${label}: invalid hash.`);
return parsed;
}
function parseClassCounts(value: unknown): Readonly<Record<string, number>> {
const raw = object(value, "E46A class counts");
const parsed: Record<string, number> = {};
for (const [key, count] of Object.entries(raw)) {
if (!key.trim()) throw new E46AContractError("E46A class name: пустое значение.");
parsed[key] = integer(count, `E46A ${key} count`);
}
return parsed;
}
function parseObject(value: unknown): E46AObject {
const item = object(value, "E46A object");
const box = array(item.box_xyxy, "E46A box").map((coordinate) => number(coordinate, "E46A coordinate"));
if (box.length !== 4 || box[2] <= box[0] || box[3] <= box[1]) {
throw new E46AContractError("E46A box: invalid geometry.");
}
const category = string(item.category, "E46A category");
if (category === "unmapped") throw new E46AContractError("E46A category unresolved.");
return {
objectId: string(item.object_id, "E46A object id"),
category,
displayCategory: category === "stroller" ? "Коляска"
: category === "laptop" ? "Ноутбук"
: category,
boxXyxy: box as [number, number, number, number],
occluded: boolean(item.occluded, "E46A occluded"),
truncated: boolean(item.truncated, "E46A truncated"),
};
}
export async function fetchE46AAiEngineeringPreannotation({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<E46AAiEngineeringPreannotationResult | null> {
const response = await fetcher("/api/v1/laboratory/e46a/results?limit=1", {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) throw new E46AContractError(`E46A LAB недоступен: HTTP ${response.status}.`);
const catalog = object(await response.json(), "E46A catalog");
exact(catalog.schema_version, "missioncore.e46a-ai-engineering-preannotation-catalog/v1", "E46A catalog schema");
const items = array(catalog.items, "E46A results");
if (!items.length) return null;
if (items.length !== 1) throw new E46AContractError("E46A catalog: нарушен размер.");
const item = object(items[0], "E46A result");
exact(item.schema_version, "missioncore.e46a-ai-engineering-preannotation-view/v1", "E46A result schema");
const resultId = string(item.result_id, "E46A result id");
const sourceE46ResultId = string(item.source_e46_result_id, "E46A source id");
if (!/^e46a-ai-engineering-preannotation-[a-f0-9]{64}$/.test(resultId)
|| !/^e46-detector-truth-island-[a-f0-9]{64}$/.test(sourceE46ResultId)) {
throw new E46AContractError("E46A result: нарушена identity.");
}
const metrics = object(item.metrics, "E46A metrics");
const taxonomy = object(item.taxonomy, "E46A taxonomy");
const decision = object(item.decision, "E46A decision");
const classes = array(taxonomy.classes, "E46A classes").map((value) => string(value, "E46A class"));
const custom = array(taxonomy.custom_classes, "E46A custom classes").map((value) => string(value, "E46A custom class"));
const objectCount = integer(metrics.object_count, "E46A object count");
if (custom.length !== 2 || custom[0] !== "laptop" || custom[1] !== "stroller") {
throw new E46AContractError("E46A custom taxonomy changed.");
}
return {
resultId,
createdAtUtc: string(item.created_at_utc, "E46A created time"),
sourceSessionId: exact(item.source_session_id, "RAVNOVES00", "E46A source session"),
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46A camera source"),
sourceE46ResultId,
metrics: {
frameCount: exact(metrics.frame_count, 32, "E46A frame count"),
reviewedFrameCount: exact(metrics.reviewed_frame_count, 32, "E46A reviewed frames"),
sourceObjectCount: optionalInteger(metrics.source_object_count, objectCount, "E46A source object count"),
objectCount,
customClassRelabelCount: integer(metrics.custom_class_relabel_count, "E46A relabel count"),
deletedFalseBoxCount: optionalInteger(metrics.deleted_false_box_count, 0, "E46A deleted false boxes"),
geometrySnappedObjectCount: optionalInteger(metrics.geometry_snapped_object_count, 0, "E46A snapped geometry"),
sourceGeometryRetainedObjectCount: optionalInteger(metrics.source_geometry_retained_object_count, objectCount, "E46A retained geometry"),
categoryCorrectedObjectCount: optionalInteger(metrics.category_corrected_object_count, 0, "E46A category corrections"),
hardNegativeFrameCount: integer(metrics.hard_negative_frame_count, "E46A hard negatives"),
independentReviewSubmissionCount: exact(metrics.independent_review_submission_count, 0, "E46A independent reviews"),
classCounts: parseClassCounts(metrics.class_counts),
},
taxonomy: {
classes,
customClasses: ["laptop", "stroller"],
unresolvedClassCount: exact(taxonomy.unresolved_class_count, 0, "E46A unresolved classes"),
},
decision: {
preannotationAvailable: exact(decision.preannotation_available, true, "E46A available"),
visualReviewComplete: exact(decision.visual_review_complete, true, "E46A visual review"),
independentReviewCountAffected: exact(decision.independent_review_count_affected, false, "E46A reviewer isolation"),
e48TruthSealOpen: exact(decision.e48_truth_seal_open, false, "E46A E48 state"),
l35AcceptanceOpen: exact(decision.l35_acceptance_open, false, "E46A L3.5 state"),
nextAction: string(decision.next_action, "E46A next action"),
},
limitations: array(item.limitations, "E46A limitations").map((value) => string(value, "E46A limitation")),
groundTruth: exact(item.ground_truth, false, "E46A truth state"),
access: exact(item.access, "read-only-ai-engineering-preannotation-not-truth", "E46A access"),
};
}
export async function fetchE46ACase(
resultId: string,
sequence: number,
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
): Promise<E46ACase> {
if (!/^e46a-ai-engineering-preannotation-[a-f0-9]{64}$/.test(resultId)
|| !Number.isInteger(sequence) || sequence < 1 || sequence > 32) {
throw new E46AContractError("E46A case request invalid.");
}
const response = await fetcher(`/api/v1/laboratory/e46a/results/${resultId}/cases/${sequence}`, {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) throw new E46AContractError(`E46A frame недоступен: HTTP ${response.status}.`);
const item = object(await response.json(), "E46A case");
exact(item.schema_version, "missioncore.e46a-ai-engineering-preannotation-case/v1", "E46A case schema");
const objects = array(item.objects, "E46A objects").map(parseObject);
if (integer(item.object_count, "E46A object count") !== objects.length) {
throw new E46AContractError("E46A case object count mismatch.");
}
return {
resultId: exact(item.result_id, resultId, "E46A case result"),
truthIslandSequence: exact(item.truth_island_sequence, sequence, "E46A case sequence"),
imageId: integer(item.image_id, "E46A image id"),
frameIndex: integer(item.frame_index, "E46A frame index"),
groupId: string(item.group_id, "E46A group"),
sessionSeconds: number(item.session_seconds, "E46A session seconds"),
sourceImageSha256: sha256(item.source_image_sha256, "E46A source hash"),
objects,
objectCount: objects.length,
cameraUrl: string(item.camera_url, "E46A camera URL"),
groundTruth: exact(item.ground_truth, false, "E46A case truth"),
independentReview: exact(item.independent_review, false, "E46A independent review"),
};
}
@@ -0,0 +1,170 @@
export type E46BMotionState = "dynamic" | "static" | "unknown";
export interface E46BObject {
objectId: string;
trackId: string;
trackIndex: number;
category: string;
displayCategory: string;
boxXyxy: readonly [number, number, number, number];
motionState: E46BMotionState;
motionConfidence: number;
motionEvidenceObservationCount: number;
trailCentersXy: readonly (readonly [number, number])[];
}
export interface E46BCase {
resultId: string;
truthIslandSequence: number;
frameIndex: number;
groupId: string;
sessionSeconds: number;
sourceImageSha256: string;
objects: readonly E46BObject[];
objectCount: number;
motionCounts: Readonly<Record<E46BMotionState, number>>;
cameraUrl: string;
}
export interface E46BTemporalMotionResult {
resultId: string;
createdAtUtc: string;
sourceE46AResultId: string;
sourceE26ResultId: string;
metrics: {
frameCount: 16;
temporalGroupCount: 4;
objectObservationCount: 136;
trackCount: 34;
matchedMotionObservationCount: number;
unmatchedMotionObservationCount: number;
dynamicTrackCount: number;
staticTrackCount: number;
unknownTrackCount: number;
visuallyReviewedFrameCount: 16;
};
decision: { stableIdsAvailable: true; motionStateAvailable: true; metricVelocityAvailable: false; nextAction: string };
limitations: readonly string[];
}
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
class ContractError extends Error {}
const object = (value: unknown, label: string): Record<string, unknown> => {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ContractError(`${label}: объект.`);
return value as Record<string, unknown>;
};
const array = (value: unknown, label: string): readonly unknown[] => {
if (!Array.isArray(value)) throw new ContractError(`${label}: массив.`);
return value;
};
const string = (value: unknown, label: string): string => {
if (typeof value !== "string" || !value.trim()) throw new ContractError(`${label}: строка.`);
return value;
};
const number = (value: unknown, label: string): number => {
if (typeof value !== "number" || !Number.isFinite(value)) throw new ContractError(`${label}: число.`);
return value;
};
const integer = (value: unknown, label: string): number => {
const parsed = number(value, label);
if (!Number.isInteger(parsed) || parsed < 0) throw new ContractError(`${label}: целое.`);
return parsed;
};
const exact = <T extends string | boolean | number>(value: unknown, expected: T, label: string): T => {
if (value !== expected) throw new ContractError(`${label}: контракт.`);
return expected;
};
const motionState = (value: unknown): E46BMotionState => {
if (value !== "dynamic" && value !== "static" && value !== "unknown") throw new ContractError("E46B motion state.");
return value;
};
const displayCategory = (category: string): string => category === "stroller" ? "Коляска"
: category === "laptop" ? "Ноутбук"
: category === "heavy_vehicle" ? "Тяжёлый транспорт"
: category === "person" ? "Человек"
: category === "car" ? "Авто" : category;
export async function fetchE46BTemporalMotion({ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise<E46BTemporalMotionResult | null> {
const response = await fetcher("/api/v1/laboratory/e46b/results?limit=1", { headers: { Accept: "application/json" }, signal });
if (!response.ok) throw new ContractError(`E46B LAB недоступен: HTTP ${response.status}.`);
const catalog = object(await response.json(), "E46B catalog");
exact(catalog.schema_version, "missioncore.e46b-temporal-motion-catalog/v1", "E46B catalog schema");
const items = array(catalog.items, "E46B results");
if (!items.length) return null;
if (items.length !== 1) throw new ContractError("E46B catalog size.");
const item = object(items[0], "E46B result");
exact(item.schema_version, "missioncore.e46b-temporal-motion-view/v1", "E46B view schema");
const resultId = string(item.result_id, "E46B result id");
if (!/^e46b-temporal-motion-[a-f0-9]{64}$/.test(resultId)) throw new ContractError("E46B identity.");
const metrics = object(item.metrics, "E46B metrics");
const decision = object(item.decision, "E46B decision");
return {
resultId,
createdAtUtc: string(item.created_at_utc, "E46B created"),
sourceE46AResultId: string(item.source_e46a_result_id, "E46B E46A source"),
sourceE26ResultId: string(item.source_e26_result_id, "E46B E26 source"),
metrics: {
frameCount: exact(metrics.frame_count, 16, "E46B frames"),
temporalGroupCount: exact(metrics.temporal_group_count, 4, "E46B groups"),
objectObservationCount: exact(metrics.object_observation_count, 136, "E46B observations"),
trackCount: exact(metrics.track_count, 34, "E46B tracks"),
matchedMotionObservationCount: integer(metrics.matched_motion_observation_count, "E46B matched"),
unmatchedMotionObservationCount: integer(metrics.unmatched_motion_observation_count, "E46B unmatched"),
dynamicTrackCount: integer(metrics.dynamic_track_count, "E46B dynamic"),
staticTrackCount: integer(metrics.static_track_count, "E46B static"),
unknownTrackCount: integer(metrics.unknown_track_count, "E46B unknown"),
visuallyReviewedFrameCount: exact(metrics.visually_reviewed_frame_count, 16, "E46B reviewed"),
},
decision: {
stableIdsAvailable: exact(decision.stable_ids_available, true, "E46B stable IDs"),
motionStateAvailable: exact(decision.motion_state_available, true, "E46B motion"),
metricVelocityAvailable: exact(decision.metric_velocity_available, false, "E46B velocity"),
nextAction: string(decision.next_action, "E46B next action"),
},
limitations: array(item.limitations, "E46B limitations").map((value) => string(value, "E46B limitation")),
};
}
export async function fetchE46BCase(resultId: string, sequence: number, { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise<E46BCase> {
if (!/^e46b-temporal-motion-[a-f0-9]{64}$/.test(resultId)) throw new ContractError("E46B result id.");
const response = await fetcher(`/api/v1/laboratory/e46b/results/${resultId}/cases/${sequence}`, { headers: { Accept: "application/json" }, signal });
if (!response.ok) throw new ContractError(`E46B frame недоступен: HTTP ${response.status}.`);
const item = object(await response.json(), "E46B case");
exact(item.schema_version, "missioncore.e46b-temporal-motion-case/v1", "E46B case schema");
const objects = array(item.objects, "E46B objects").map((raw): E46BObject => {
const value = object(raw, "E46B object");
const box = array(value.box_xyxy, "E46B box").map((coordinate) => number(coordinate, "E46B coordinate"));
const trail = array(value.trail_centers_xy, "E46B trail").map((rawPoint) => {
const point = array(rawPoint, "E46B point").map((coordinate) => number(coordinate, "E46B point coordinate"));
if (point.length !== 2) throw new ContractError("E46B point geometry.");
return point as [number, number];
});
if (box.length !== 4 || box[2] <= box[0] || box[3] <= box[1]) throw new ContractError("E46B box geometry.");
const category = string(value.category, "E46B category");
return {
objectId: string(value.object_id, "E46B object id"),
trackId: string(value.track_id, "E46B track id"),
trackIndex: integer(value.track_index, "E46B track index"),
category,
displayCategory: displayCategory(category),
boxXyxy: box as [number, number, number, number],
motionState: motionState(value.motion_state),
motionConfidence: number(value.motion_confidence, "E46B motion confidence"),
motionEvidenceObservationCount: integer(value.motion_evidence_observation_count, "E46B evidence count"),
trailCentersXy: trail,
};
});
const rawCounts = object(item.motion_counts, "E46B motion counts");
return {
resultId: exact(item.result_id, resultId, "E46B case result"),
truthIslandSequence: integer(item.truth_island_sequence, "E46B sequence"),
frameIndex: integer(item.frame_index, "E46B frame"),
groupId: string(item.group_id, "E46B group"),
sessionSeconds: number(item.session_seconds, "E46B time"),
sourceImageSha256: string(item.source_image_sha256, "E46B image hash"),
objects,
objectCount: integer(item.object_count, "E46B object count"),
motionCounts: { dynamic: integer(rawCounts.dynamic, "E46B dynamic count"), static: integer(rawCounts.static, "E46B static count"), unknown: integer(rawCounts.unknown, "E46B unknown count") },
cameraUrl: string(item.camera_url, "E46B camera URL"),
};
}
@@ -0,0 +1,281 @@
export type E46CMotionState = "dynamic" | "static" | "unknown";
export interface E46CCameraObject {
objectId: string;
category: string;
displayCategory: string;
boxXyxy: readonly [number, number, number, number];
routeTrackId: number | null;
worldTrackId: number | null;
motionState: E46CMotionState;
}
export interface E46CWorldObject {
worldTrackId: number;
routeTrackId: number;
category: string;
motionState: E46CMotionState;
positionMapM: readonly [number, number, number];
occupancyFootprintMapXy: readonly (readonly [number, number])[];
occupancyEvidenceCurrent: boolean;
occupancyCellCount: number;
}
export interface E46CCase {
resultId: string;
truthIslandSequence: number;
frameIndex: number;
groupId: string;
sessionSeconds: number;
sourceImageSha256: string;
fusionState: string;
objects: readonly E46CCameraObject[];
objectCount: number;
matchedRouteObjectCount: number;
worldObjects: readonly E46CWorldObject[];
worldObjectCount: number;
cameraUrl: string;
}
export interface E46CFullReplayWorldTracksResult {
resultId: string;
createdAtUtc: string;
sourceE46AResultId: string;
sourceE26ResultId: string;
metrics: {
routeFrameCount: 4489;
routeSpanSeconds: number;
fusionObservationCount: number;
sourceTrackCount: number;
worldTrackCandidateCount: number;
worldTrackCount: number;
sourceTrackWorldBoundCount: number;
worldFrameCount: number;
worldObservationCount: number;
worldCurrentObservationCount: number;
worldHeldObservationCount: number;
visualSampleFrameCount: 32;
sampleObjectCount: number;
sampleMatchedRouteObjectCount: number;
sampleUnmatchedObjectCount: number;
sampleWorldFrameCount: number;
motionObservationCounts: Readonly<Record<E46CMotionState, number>>;
};
acceptance: { benchmarkPassedEvents: 11; benchmarkTotalEvents: 11 };
decision: { routeTrackLayerAvailable: true; worldOccupiedLayerAvailable: true; unknownRemainsOccupied: true; freeSpaceAvailable: false; nextAction: string };
limitations: readonly string[];
}
export interface E46CVideoObject {
boxXyxy: readonly [number, number, number, number];
category: string;
displayCategory: string;
score: number;
routeTrackId: number;
worldTrackId: number | null;
motionState: E46CMotionState;
motionConfidence: number;
trackHits: number;
trackAgeSeconds: number;
cameraEvidenceCurrent: boolean;
worldEvidenceCurrent: boolean;
}
export interface E46CVideoFrame {
frameIndex: number;
sessionSeconds: number;
fusionState: string;
objects: readonly E46CVideoObject[];
}
export interface E46CVideoReviewWindow {
id: string;
kind: string;
classGroup: string;
startSeconds: number;
endSeconds: number;
targetSourceTrackIds: readonly number[];
}
export interface E46CVideoOverlay {
resultId: string;
recordedSourceSessionId: string;
recordedSourceId: "sensor.camera.right";
inputSha256: string;
imageWidth: 800;
imageHeight: 600;
timelineStartSeconds: number;
timelineEndSeconds: number;
frameCount: 4489;
frames: readonly E46CVideoFrame[];
reviewWindows: readonly E46CVideoReviewWindow[];
}
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
class ContractError extends Error {}
const object = (value: unknown, label: string): Record<string, unknown> => { if (!value || typeof value !== "object" || Array.isArray(value)) throw new ContractError(`${label}: объект.`); return value as Record<string, unknown>; };
const array = (value: unknown, label: string): readonly unknown[] => { if (!Array.isArray(value)) throw new ContractError(`${label}: массив.`); return value; };
const string = (value: unknown, label: string): string => { if (typeof value !== "string" || !value.trim()) throw new ContractError(`${label}: строка.`); return value; };
const number = (value: unknown, label: string): number => { if (typeof value !== "number" || !Number.isFinite(value)) throw new ContractError(`${label}: число.`); return value; };
const integer = (value: unknown, label: string): number => { const parsed = number(value, label); if (!Number.isInteger(parsed) || parsed < 0) throw new ContractError(`${label}: целое.`); return parsed; };
const optionalInteger = (value: unknown, label: string): number | null => value === null ? null : integer(value, label);
const exact = <T extends string | boolean | number>(value: unknown, expected: T, label: string): T => { if (value !== expected) throw new ContractError(`${label}: контракт.`); return expected; };
const motionState = (value: unknown): E46CMotionState => { if (value !== "dynamic" && value !== "static" && value !== "unknown") throw new ContractError("E46C motion state."); return value; };
const displayCategory = (category: string): string => category === "stroller" ? "Коляска" : category === "laptop" ? "Ноутбук" : category === "heavy_vehicle" || category === "truck" || category === "bus" ? "Тяжёлый транспорт" : category === "person" ? "Человек" : category === "car" ? "Авто" : category;
const vector = (value: unknown, length: number, label: string): number[] => { const parsed = array(value, label).map((item) => number(item, label)); if (parsed.length !== length) throw new ContractError(`${label}: geometry.`); return parsed; };
export async function fetchE46CFullReplayWorldTracks({ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise<E46CFullReplayWorldTracksResult | null> {
const response = await fetcher("/api/v1/laboratory/e46c/results?limit=1", { headers: { Accept: "application/json" }, signal });
if (!response.ok) throw new ContractError(`E46C LAB недоступен: HTTP ${response.status}.`);
const catalog = object(await response.json(), "E46C catalog"); exact(catalog.schema_version, "missioncore.e46c-full-replay-world-tracks-catalog/v1", "E46C catalog schema");
const items = array(catalog.items, "E46C results"); if (!items.length) return null; if (items.length !== 1) throw new ContractError("E46C catalog size.");
const item = object(items[0], "E46C result"); exact(item.schema_version, "missioncore.e46c-full-replay-world-tracks-view/v1", "E46C view schema");
const resultId = string(item.result_id, "E46C result id"); if (!/^e46c-full-replay-world-tracks-[a-f0-9]{64}$/.test(resultId)) throw new ContractError("E46C identity.");
const metrics = object(item.metrics, "E46C metrics"); const counts = object(metrics.motion_observation_counts, "E46C motion counts"); const acceptance = object(item.acceptance, "E46C acceptance"); const decision = object(item.decision, "E46C decision");
return { resultId, createdAtUtc: string(item.created_at_utc, "E46C created"), sourceE46AResultId: string(item.source_e46a_result_id, "E46C E46A"), sourceE26ResultId: string(item.source_e26_result_id, "E46C E26"), metrics: { routeFrameCount: exact(metrics.route_frame_count, 4489, "E46C route frames"), routeSpanSeconds: number(metrics.route_span_seconds, "E46C span"), fusionObservationCount: integer(metrics.fusion_observation_count, "E46C observations"), sourceTrackCount: integer(metrics.source_track_count, "E46C source tracks"), worldTrackCandidateCount: integer(metrics.world_track_candidate_count, "E46C world candidates"), worldTrackCount: integer(metrics.world_track_count, "E46C world tracks"), sourceTrackWorldBoundCount: integer(metrics.source_track_world_bound_count, "E46C bound tracks"), worldFrameCount: integer(metrics.world_frame_count, "E46C world frames"), worldObservationCount: integer(metrics.world_observation_count, "E46C world observations"), worldCurrentObservationCount: integer(metrics.world_current_observation_count, "E46C current world"), worldHeldObservationCount: integer(metrics.world_held_observation_count, "E46C held world"), visualSampleFrameCount: exact(metrics.visual_sample_frame_count, 32, "E46C samples"), sampleObjectCount: integer(metrics.sample_object_count, "E46C sample objects"), sampleMatchedRouteObjectCount: integer(metrics.sample_matched_route_object_count, "E46C matched"), sampleUnmatchedObjectCount: integer(metrics.sample_unmatched_object_count, "E46C unmatched"), sampleWorldFrameCount: integer(metrics.sample_world_frame_count, "E46C sample world frames"), motionObservationCounts: { dynamic: integer(counts.dynamic, "E46C dynamic"), static: integer(counts.static, "E46C static"), unknown: integer(counts.unknown, "E46C unknown") } }, acceptance: { benchmarkPassedEvents: exact(acceptance.benchmark_passed_events, 11, "E46C passed events"), benchmarkTotalEvents: exact(acceptance.benchmark_total_events, 11, "E46C total events") }, decision: { routeTrackLayerAvailable: exact(decision.route_track_layer_available, true, "E46C route layer"), worldOccupiedLayerAvailable: exact(decision.world_occupied_layer_available, true, "E46C world layer"), unknownRemainsOccupied: exact(decision.unknown_remains_occupied, true, "E46C unknown"), freeSpaceAvailable: exact(decision.free_space_available, false, "E46C free space"), nextAction: string(decision.next_action, "E46C next") }, limitations: array(item.limitations, "E46C limitations").map((value) => string(value, "E46C limitation")) };
}
export async function fetchE46CCase(resultId: string, sequence: number, { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise<E46CCase> {
const response = await fetcher(`/api/v1/laboratory/e46c/results/${resultId}/cases/${sequence}`, { headers: { Accept: "application/json" }, signal }); if (!response.ok) throw new ContractError(`E46C frame недоступен: HTTP ${response.status}.`);
const item = object(await response.json(), "E46C case"); exact(item.schema_version, "missioncore.e46c-full-replay-world-track-case/v1", "E46C case schema");
const objects = array(item.objects, "E46C objects").map((raw): E46CCameraObject => { const value = object(raw, "E46C object"); const category = string(value.category, "E46C category"); const box = vector(value.box_xyxy, 4, "E46C box") as [number, number, number, number]; return { objectId: string(value.object_id, "E46C object id"), category, displayCategory: displayCategory(category), boxXyxy: box, routeTrackId: optionalInteger(value.route_track_id, "E46C route track"), worldTrackId: optionalInteger(value.world_track_id, "E46C world track"), motionState: motionState(value.motion_state) }; });
const worldObjects = array(item.world_objects, "E46C world objects").map((raw): E46CWorldObject => { const value = object(raw, "E46C world object"); return { worldTrackId: integer(value.world_track_id, "E46C world id"), routeTrackId: integer(value.route_track_id, "E46C route id"), category: string(value.category, "E46C world category"), motionState: motionState(value.motion_state), positionMapM: vector(value.position_map_m, 3, "E46C position") as [number, number, number], occupancyFootprintMapXy: array(value.occupancy_footprint_map_xy, "E46C footprint").map((point) => vector(point, 2, "E46C footprint point") as [number, number]), occupancyEvidenceCurrent: exact(value.occupancy_evidence_current, Boolean(value.occupancy_evidence_current), "E46C current") as boolean, occupancyCellCount: integer(value.occupancy_cell_count, "E46C cells") }; });
return { resultId: exact(item.result_id, resultId, "E46C case result"), truthIslandSequence: integer(item.truth_island_sequence, "E46C sequence"), frameIndex: integer(item.frame_index, "E46C frame"), groupId: string(item.group_id, "E46C group"), sessionSeconds: number(item.session_seconds, "E46C time"), sourceImageSha256: string(item.source_image_sha256, "E46C image hash"), fusionState: string(item.fusion_state, "E46C fusion"), objects, objectCount: integer(item.object_count, "E46C object count"), matchedRouteObjectCount: integer(item.matched_route_object_count, "E46C matched count"), worldObjects, worldObjectCount: integer(item.world_object_count, "E46C world count"), cameraUrl: string(item.camera_url, "E46C camera URL") };
}
export async function fetchE46CVideoOverlay(
resultId: string,
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
): Promise<E46CVideoOverlay> {
const response = await fetcher(
`/api/v1/laboratory/e46c/results/${resultId}/video-overlay`,
{ headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new ContractError(`E46C video overlay недоступен: HTTP ${response.status}.`);
}
const payload = object(await response.json(), "E46C video overlay");
exact(
payload.schema_version,
"missioncore.e46c-recorded-video-overlay/v1",
"E46C video overlay schema",
);
exact(payload.result_id, resultId, "E46C video result");
exact(payload.ground_truth, false, "E46C video truth");
const recordedSource = object(payload.recorded_source, "E46C recorded source");
exact(recordedSource.source_id, "sensor.camera.right", "E46C video source");
exact(
recordedSource.synchronization,
"host-arrival-best-effort",
"E46C video synchronization",
);
const inputSha256 = string(recordedSource.input_sha256, "E46C video input hash");
if (!/^[a-f0-9]{64}$/.test(inputSha256)) throw new ContractError("E46C video input hash.");
const frames = array(payload.frames, "E46C video frames").map(
(raw, frameSequence): E46CVideoFrame => {
const frame = object(raw, "E46C video frame");
const frameIndex = integer(frame.frame_index, "E46C video frame index");
if (frameIndex !== frameSequence) throw new ContractError("E46C video frame order.");
return {
frameIndex,
sessionSeconds: number(frame.session_seconds, "E46C video frame time"),
fusionState: string(frame.fusion_state, "E46C video fusion state"),
objects: array(frame.objects, "E46C video objects").map((rawObject) => {
const item = object(rawObject, "E46C video object");
const category = string(item.category, "E46C video category");
return {
boxXyxy: vector(item.bbox_xyxy, 4, "E46C video box") as [number, number, number, number],
category,
displayCategory: displayCategory(category),
score: number(item.score, "E46C video score"),
routeTrackId: integer(item.route_track_id, "E46C video route track"),
worldTrackId: optionalInteger(item.world_track_id, "E46C video world track"),
motionState: motionState(item.motion_state),
motionConfidence: number(item.motion_confidence, "E46C video motion confidence"),
trackHits: integer(item.track_hits, "E46C video track hits"),
trackAgeSeconds: number(item.track_age_seconds, "E46C video track age"),
cameraEvidenceCurrent: exact(
item.camera_evidence_current,
Boolean(item.camera_evidence_current),
"E46C camera evidence",
) as boolean,
worldEvidenceCurrent: exact(
item.world_evidence_current,
Boolean(item.world_evidence_current),
"E46C world evidence",
) as boolean,
};
}),
};
},
);
const reviewWindows = array(payload.review_windows, "E46C video review windows").map(
(raw): E46CVideoReviewWindow => {
const window = object(raw, "E46C video review window");
return {
id: string(window.id, "E46C video review id"),
kind: string(window.kind, "E46C video review kind"),
classGroup: string(window.class_group, "E46C video review class"),
startSeconds: number(window.start_seconds, "E46C video review start"),
endSeconds: number(window.end_seconds, "E46C video review end"),
targetSourceTrackIds: array(
window.target_source_track_ids,
"E46C video review targets",
).map((value) => integer(value, "E46C video review target")),
};
},
);
const timelineStartSeconds = number(
payload.timeline_start_seconds,
"E46C video timeline start",
);
const timelineEndSeconds = number(
payload.timeline_end_seconds,
"E46C video timeline end",
);
if (
frames.length !== 4489 ||
exact(payload.frame_count, 4489, "E46C video frame count") !== frames.length ||
frames[0]?.sessionSeconds !== timelineStartSeconds ||
frames.at(-1)?.sessionSeconds !== timelineEndSeconds
) {
throw new ContractError("E46C video timeline coverage.");
}
return {
resultId,
recordedSourceSessionId: string(
recordedSource.session_id,
"E46C recorded source session",
),
recordedSourceId: "sensor.camera.right",
inputSha256,
imageWidth: exact(payload.image_width, 800, "E46C video width"),
imageHeight: exact(payload.image_height, 600, "E46C video height"),
timelineStartSeconds,
timelineEndSeconds,
frameCount: 4489,
frames,
reviewWindows,
};
}
export function selectE46CVideoFrame(
frames: readonly E46CVideoFrame[],
sessionSeconds: number,
): E46CVideoFrame | null {
if (!frames.length || !Number.isFinite(sessionSeconds)) return null;
let low = 0;
let high = frames.length - 1;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const frame = frames[middle];
if (!frame) break;
if (frame.sessionSeconds <= sessionSeconds) low = middle + 1;
else high = middle - 1;
}
const before = frames[Math.max(0, high)] ?? null;
const after = frames[Math.min(frames.length - 1, low)] ?? null;
if (!before) return after;
if (!after) return before;
return Math.abs(before.sessionSeconds - sessionSeconds) <=
Math.abs(after.sessionSeconds - sessionSeconds)
? before
: after;
}
@@ -0,0 +1,291 @@
export type E46DTemporalSignalKind =
| "layer-blackout"
| "camera-evidence-hold"
| "route-layer-gap"
| "route-id-rebirth-candidate"
| "bbox-jump"
| "motion-state-flap"
| "world-binding-flap"
| "short-track-burst";
export type E46DTemporalPriority = "critical" | "high" | "medium";
export interface E46DReviewClip {
clipId: string;
rank: number;
priority: E46DTemporalPriority;
kind: E46DTemporalSignalKind;
signalId: string;
startSeconds: number;
eventStartSeconds: number;
eventEndSeconds: number;
endSeconds: number;
startFrame: number;
endFrame: number;
routeTrackIds: readonly number[];
worldTrackIds: readonly number[];
evidence: Readonly<Record<string, string | number | boolean>>;
}
export interface E46DTemporalFailureAuditResult {
resultId: string;
createdAtUtc: string;
sourceE46CResultId: string;
sourceE26ResultId: string;
metrics: {
routeFrameCount: 4489;
routeSpanSeconds: number;
objectObservationCount: number;
routeTrackCount: number;
zeroObjectFrameCount: number;
zeroObjectFrameFraction: number;
cameraHeldObservationCount: number;
cameraHeldObservationFraction: number;
detectorHoldEpisodeCount: number;
layerBlackoutEpisodeCount: number;
routeLayerGapEpisodeCount: number;
routeIdRebirthCandidateCount: number;
bboxJumpEpisodeCount: number;
motionStateFlapEpisodeCount: number;
worldBindingFlapEpisodeCount: number;
shortRouteTrackCount: number;
shortRouteTrackFraction: number;
shortTrackBurstEpisodeCount: number;
failureSignalCount: number;
reviewClipCount: number;
temporalContinuityPassed: boolean;
};
acceptance: {
fullRouteAccounted: true;
temporalContinuityPassed: boolean;
independentTruthAvailable: false;
navigationOrSafetyAccepted: false;
};
decision: {
temporalRegressionConfirmed: boolean;
detectorGapVisible: boolean;
routeFragmentationVisible: boolean;
worldBindingInstabilityVisible: boolean;
nextAction: string;
};
limitations: readonly string[];
reviewClips: readonly E46DReviewClip[];
}
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
class ContractError extends Error {}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ContractError(`${label}: ожидалась строка.`);
}
return value;
}
function number(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new ContractError(`${label}: ожидалось число.`);
}
return value;
}
function integer(value: unknown, label: string): number {
const parsed = number(value, label);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
}
return parsed;
}
function exact<T extends string | number | boolean>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
return expected;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") throw new ContractError(`${label}: ожидался boolean.`);
return value;
}
function priority(value: unknown): E46DTemporalPriority {
if (value !== "critical" && value !== "high" && value !== "medium") {
throw new ContractError("E46D priority: неизвестное значение.");
}
return value;
}
function kind(value: unknown): E46DTemporalSignalKind {
if (
value !== "layer-blackout"
&& value !== "camera-evidence-hold"
&& value !== "route-layer-gap"
&& value !== "route-id-rebirth-candidate"
&& value !== "bbox-jump"
&& value !== "motion-state-flap"
&& value !== "world-binding-flap"
&& value !== "short-track-burst"
) {
throw new ContractError("E46D signal kind: неизвестное значение.");
}
return value;
}
function shaResult(value: unknown, prefix: string, label: string): string {
const parsed = string(value, label);
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
throw new ContractError(`${label}: нарушена идентичность.`);
}
return parsed;
}
function evidence(value: unknown): Readonly<Record<string, string | number | boolean>> {
const raw = object(value, "E46D clip evidence");
const parsed: Record<string, string | number | boolean> = {};
for (const [key, item] of Object.entries(raw)) {
if (
typeof item !== "string"
&& typeof item !== "number"
&& typeof item !== "boolean"
) {
throw new ContractError("E46D clip evidence: неизвестное значение.");
}
parsed[key] = item;
}
return parsed;
}
function parseClip(value: unknown, expectedRank: number): E46DReviewClip {
const item = object(value, "E46D review clip");
exact(item.schema_version, "missioncore.e46d-temporal-review-clip/v1", "E46D clip schema");
const rank = integer(item.rank, "E46D clip rank");
if (rank !== expectedRank) throw new ContractError("E46D clip order: нарушен контракт.");
const clipId = string(item.clip_id, "E46D clip id");
const signalId = string(item.signal_id, "E46D signal id");
if (!/^e46d-clip-\d{2}-[a-f0-9]{20}$/.test(clipId) || !/^e46d-signal-[a-f0-9]{20}$/.test(signalId)) {
throw new ContractError("E46D clip identity: нарушен контракт.");
}
const startSeconds = number(item.start_seconds, "E46D clip start");
const eventStartSeconds = number(item.event_start_seconds, "E46D event start");
const eventEndSeconds = number(item.event_end_seconds, "E46D event end");
const endSeconds = number(item.end_seconds, "E46D clip end");
if (!(startSeconds <= eventStartSeconds && eventStartSeconds <= eventEndSeconds && eventEndSeconds <= endSeconds)) {
throw new ContractError("E46D clip timeline: нарушен контракт.");
}
return {
clipId,
rank,
priority: priority(item.priority),
kind: kind(item.kind),
signalId,
startSeconds,
eventStartSeconds,
eventEndSeconds,
endSeconds,
startFrame: integer(item.start_frame, "E46D clip start frame"),
endFrame: integer(item.end_frame, "E46D clip end frame"),
routeTrackIds: array(item.route_track_ids, "E46D clip route tracks").map((entry) => integer(entry, "E46D route track")),
worldTrackIds: array(item.world_track_ids, "E46D clip world tracks").map((entry) => integer(entry, "E46D world track")),
evidence: evidence(item.evidence),
};
}
export async function fetchE46DTemporalFailureAudit({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<E46DTemporalFailureAuditResult | null> {
const response = await fetcher("/api/v1/laboratory/e46d/results?limit=1", {
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) throw new ContractError(`E46D LAB недоступен: HTTP ${response.status}.`);
const catalog = object(await response.json(), "E46D catalog");
exact(
catalog.schema_version,
"missioncore.e46d-temporal-failure-audit-catalog/v1",
"E46D catalog schema",
);
const items = array(catalog.items, "E46D results");
if (!items.length) return null;
if (items.length !== 1) throw new ContractError("E46D catalog size: нарушен контракт.");
const item = object(items[0], "E46D result");
exact(
item.schema_version,
"missioncore.e46d-temporal-failure-audit-view/v1",
"E46D view schema",
);
exact(item.ground_truth, false, "E46D truth authority");
const metrics = object(item.metrics, "E46D metrics");
const acceptance = object(item.acceptance, "E46D acceptance");
const decision = object(item.decision, "E46D decision");
const clips = array(item.review_clips, "E46D review clips").map((clip, index) => (
parseClip(clip, index + 1)
));
const result: E46DTemporalFailureAuditResult = {
resultId: shaResult(item.result_id, "e46d-temporal-failure-audit", "E46D result id"),
createdAtUtc: string(item.created_at_utc, "E46D created"),
sourceE46CResultId: shaResult(item.source_e46c_result_id, "e46c-full-replay-world-tracks", "E46D E46C source"),
sourceE26ResultId: shaResult(item.source_e26_result_id, "e10-integrated-perception", "E46D E26 source"),
metrics: {
routeFrameCount: exact(metrics.route_frame_count, 4489, "E46D route frames"),
routeSpanSeconds: number(metrics.route_span_seconds, "E46D route span"),
objectObservationCount: integer(metrics.object_observation_count, "E46D observations"),
routeTrackCount: integer(metrics.route_track_count, "E46D route tracks"),
zeroObjectFrameCount: integer(metrics.zero_object_frame_count, "E46D zero frames"),
zeroObjectFrameFraction: number(metrics.zero_object_frame_fraction, "E46D zero fraction"),
cameraHeldObservationCount: integer(metrics.camera_held_observation_count, "E46D held observations"),
cameraHeldObservationFraction: number(metrics.camera_held_observation_fraction, "E46D held fraction"),
detectorHoldEpisodeCount: integer(metrics.detector_hold_episode_count, "E46D detector holds"),
layerBlackoutEpisodeCount: integer(metrics.layer_blackout_episode_count, "E46D blackouts"),
routeLayerGapEpisodeCount: integer(metrics.route_layer_gap_episode_count, "E46D route gaps"),
routeIdRebirthCandidateCount: integer(metrics.route_id_rebirth_candidate_count, "E46D ID candidates"),
bboxJumpEpisodeCount: integer(metrics.bbox_jump_episode_count, "E46D bbox jumps"),
motionStateFlapEpisodeCount: integer(metrics.motion_state_flap_episode_count, "E46D motion flaps"),
worldBindingFlapEpisodeCount: integer(metrics.world_binding_flap_episode_count, "E46D world flaps"),
shortRouteTrackCount: integer(metrics.short_route_track_count, "E46D short tracks"),
shortRouteTrackFraction: number(metrics.short_route_track_fraction, "E46D short fraction"),
shortTrackBurstEpisodeCount: integer(metrics.short_track_burst_episode_count, "E46D short bursts"),
failureSignalCount: integer(metrics.failure_signal_count, "E46D failure signals"),
reviewClipCount: integer(metrics.review_clip_count, "E46D review clips"),
temporalContinuityPassed: boolean(metrics.temporal_continuity_passed, "E46D continuity"),
},
acceptance: {
fullRouteAccounted: exact(acceptance.full_route_accounted, true, "E46D full route"),
temporalContinuityPassed: boolean(acceptance.temporal_continuity_passed, "E46D acceptance continuity"),
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46D truth"),
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46D safety"),
},
decision: {
temporalRegressionConfirmed: boolean(decision.temporal_regression_confirmed, "E46D regression"),
detectorGapVisible: boolean(decision.detector_gap_visible, "E46D detector gap"),
routeFragmentationVisible: boolean(decision.route_fragmentation_visible, "E46D fragmentation"),
worldBindingInstabilityVisible: boolean(decision.world_binding_instability_visible, "E46D world binding"),
nextAction: string(decision.next_action, "E46D next action"),
},
limitations: array(item.limitations, "E46D limitations").map((value) => string(value, "E46D limitation")),
reviewClips: clips,
};
if (result.metrics.reviewClipCount !== clips.length) {
throw new ContractError("E46D review clip accounting: нарушен контракт.");
}
return result;
}
@@ -0,0 +1,219 @@
export interface E46ELaboratoryMethod {
completeness: "complete";
executionClass: "hybrid";
pipelineId: string;
components: readonly {
kind: "source" | "tool" | "model" | "algorithm" | "runtime";
name: string;
version: string;
role: string;
identitySha256: string;
}[];
}
export interface E46EReadyStackResult {
resultId: string;
createdAtUtc: string;
sourceSessionId: string;
cameraSourceId: "sensor.camera.right";
metrics: {
frameCount: 4489;
routeDurationSeconds: number;
detectionObservationCount: number;
trackObservationCount: number;
detectionBoxClippedCount: number;
trackBoxClippedCount: number;
uniqueTrackCount: number;
meanTrackedObjectsPerFrame: number;
zeroDetectionFrameCount: number;
zeroTrackFrameCount: number;
trackerRecoveredFrameCount: number;
fullLayerBlackoutEventCount: number;
routeIdGapEventCount: number;
shortTrackCount: number;
shortTrackFraction: number;
trackClassSwitchCount: number;
};
acceptance: {
fullRouteAccounted: true;
stockDetectorTrackerExecuted: true;
visualOverlayAvailable: true;
independentTruthAvailable: false;
navigationOrSafetyAccepted: false;
};
decision: {
readyStackBaselineAvailable: true;
customTemporalLogicUsed: false;
nextAction: string;
};
method: E46ELaboratoryMethod;
limitations: readonly string[];
video: {
url: string;
mediaType: "video/mp4";
byteLength: number;
sha256: string;
width: 800;
height: 600;
};
}
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
class ContractError extends Error {}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ContractError(`${label}: ожидалась строка.`);
}
return value;
}
function number(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new ContractError(`${label}: ожидалось число.`);
}
return value;
}
function integer(value: unknown, label: string): number {
const parsed = number(value, label);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
}
return parsed;
}
function exact<T extends string | number | boolean>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
return expected;
}
function sha(value: unknown, label: string): string {
const parsed = string(value, label);
if (!/^[a-f0-9]{64}$/.test(parsed)) {
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
}
return parsed;
}
function method(value: unknown): E46ELaboratoryMethod {
const raw = object(value, "E46E method");
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46E method schema");
exact(raw.completeness, "complete", "E46E method completeness");
exact(raw.execution_class, "hybrid", "E46E execution class");
const components = array(raw.components, "E46E method components").map((entry) => {
const component = object(entry, "E46E method component");
const kind = string(component.kind, "E46E component kind");
if (!(["source", "tool", "model", "algorithm", "runtime"] as const).includes(
kind as "source",
)) {
throw new ContractError("E46E component kind: неизвестное значение.");
}
return {
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
name: string(component.name, "E46E component name"),
version: string(component.version, "E46E component version"),
role: string(component.role, "E46E component role"),
identitySha256: sha(component.identity_sha256, "E46E component identity"),
};
});
return {
completeness: "complete",
executionClass: "hybrid",
pipelineId: string(raw.pipeline_id, "E46E pipeline"),
components,
};
}
export async function fetchE46EReadyStack({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<E46EReadyStackResult | null> {
const response = await fetcher("/api/v1/laboratory/e46e/results?limit=1", {
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) throw new ContractError(`E46E LAB недоступен: HTTP ${response.status}.`);
const catalog = object(await response.json(), "E46E catalog");
exact(catalog.schema_version, "missioncore.e46e-ready-stack-catalog/v1", "E46E catalog schema");
const items = array(catalog.items, "E46E results");
if (!items.length) return null;
if (items.length !== 1) throw new ContractError("E46E catalog size: нарушен контракт.");
const item = object(items[0], "E46E result");
exact(item.schema_version, "missioncore.e46e-ready-stack-view/v1", "E46E view schema");
exact(item.ground_truth, false, "E46E truth authority");
const metrics = object(item.metrics, "E46E metrics");
const acceptance = object(item.acceptance, "E46E acceptance");
const decision = object(item.decision, "E46E decision");
const video = object(item.video, "E46E video");
const resultId = string(item.result_id, "E46E result id");
if (!/^e46e-ready-stack-[a-f0-9]{64}$/.test(resultId)) {
throw new ContractError("E46E result identity: нарушен контракт.");
}
return {
resultId,
createdAtUtc: string(item.created_at_utc, "E46E created"),
sourceSessionId: string(item.source_session_id, "E46E source session"),
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46E camera"),
metrics: {
frameCount: exact(metrics.frame_count, 4489, "E46E frames"),
routeDurationSeconds: number(metrics.route_duration_seconds, "E46E duration"),
detectionObservationCount: integer(metrics.detection_observation_count, "E46E detections"),
trackObservationCount: integer(metrics.track_observation_count, "E46E track observations"),
detectionBoxClippedCount: integer(metrics.detection_box_clipped_count, "E46E clipped detections"),
trackBoxClippedCount: integer(metrics.track_box_clipped_count, "E46E clipped tracks"),
uniqueTrackCount: integer(metrics.unique_track_count, "E46E tracks"),
meanTrackedObjectsPerFrame: number(metrics.mean_tracked_objects_per_frame, "E46E mean objects"),
zeroDetectionFrameCount: integer(metrics.zero_detection_frame_count, "E46E zero detections"),
zeroTrackFrameCount: integer(metrics.zero_track_frame_count, "E46E zero tracks"),
trackerRecoveredFrameCount: integer(metrics.tracker_recovered_frame_count, "E46E recovered frames"),
fullLayerBlackoutEventCount: integer(metrics.full_layer_blackout_event_count, "E46E blackouts"),
routeIdGapEventCount: integer(metrics.route_id_gap_event_count, "E46E ID gaps"),
shortTrackCount: integer(metrics.short_track_count, "E46E short tracks"),
shortTrackFraction: number(metrics.short_track_fraction, "E46E short fraction"),
trackClassSwitchCount: integer(metrics.track_class_switch_count, "E46E class switches"),
},
acceptance: {
fullRouteAccounted: exact(acceptance.full_route_accounted, true, "E46E full route"),
stockDetectorTrackerExecuted: exact(acceptance.stock_detector_tracker_executed, true, "E46E ready stack"),
visualOverlayAvailable: exact(acceptance.visual_overlay_available, true, "E46E visual"),
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46E truth"),
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46E safety"),
},
decision: {
readyStackBaselineAvailable: exact(decision.ready_stack_baseline_available, true, "E46E baseline"),
customTemporalLogicUsed: exact(decision.custom_temporal_logic_used, false, "E46E custom logic"),
nextAction: string(decision.next_action, "E46E next action"),
},
method: method(item.method),
limitations: array(item.limitations, "E46E limitations").map((entry) => string(entry, "E46E limitation")),
video: {
url: string(video.url, "E46E video URL"),
mediaType: exact(video.media_type, "video/mp4", "E46E media type"),
byteLength: integer(video.byte_length, "E46E video bytes"),
sha256: sha(video.sha256, "E46E video SHA"),
width: exact(video.width, 800, "E46E video width"),
height: exact(video.height, 600, "E46E video height"),
},
};
}
@@ -0,0 +1,289 @@
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
export interface E46FMetrics {
frameCount: 4489;
routeDurationSeconds: number;
detectionObservationCount: number;
trackObservationCount: number;
detectionBoxClippedCount: number;
trackBoxClippedCount: number;
uniqueTrackCount: number;
meanTrackedObjectsPerFrame: number;
zeroDetectionFrameCount: number;
zeroTrackFrameCount: number;
trackerRecoveredFrameCount: number;
fullLayerBlackoutEventCount: number;
routeIdGapEventCount: number;
shortTrackCount: number;
shortTrackFraction: number;
trackClassSwitchCount: number;
}
export interface E46FLargeBoxTriage {
observationCount: number;
frameCount: number;
trackIdCount: number;
classObservations: Readonly<Record<string, number>>;
}
export interface E46FDashCamBakeoffResult {
resultId: string;
createdAtUtc: string;
sourceSessionId: string;
cameraSourceId: "sensor.camera.right";
metrics: E46FMetrics;
acceptance: {
fullRouteAccounted: true;
stockDetectorTrackerExecuted: true;
controlledDetectorOnlyChange: true;
visualOverlayAvailable: true;
independentTruthAvailable: false;
navigationOrSafetyAccepted: false;
};
method: E46ELaboratoryMethod;
limitations: readonly string[];
comparison: {
baselineResultId: string;
baselineMetrics: E46FMetrics;
delta: {
zeroTrackFrameCount: number;
fullLayerBlackoutEventCount: number;
uniqueTrackCount: number;
shortTrackFraction: number;
};
largeBoxVisualTriage: {
areaRatioThreshold: 0.2;
candidate: E46FLargeBoxTriage;
baseline: E46FLargeBoxTriage;
interpretation: string;
};
visualReview: {
status: "rejected-semantic-regression";
sampleVideoSeconds: readonly number[];
finding: string;
nextAction: string;
};
verdict: "reject-dashcamnet-on-unrectified-fisheye";
};
video: {
url: string;
mediaType: "video/mp4";
byteLength: number;
sha256: string;
width: 800;
height: 600;
};
}
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
class ContractError extends Error {}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ContractError(`${label}: ожидалась строка.`);
}
return value;
}
function number(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new ContractError(`${label}: ожидалось число.`);
}
return value;
}
function integer(value: unknown, label: string): number {
const parsed = number(value, label);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
}
return parsed;
}
function exact<T extends string | number | boolean>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
return expected;
}
function sha(value: unknown, label: string): string {
const parsed = string(value, label);
if (!/^[a-f0-9]{64}$/.test(parsed)) {
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
}
return parsed;
}
function signedNumber(value: unknown, label: string): number {
return number(value, label);
}
function metrics(value: unknown, label: string): E46FMetrics {
const raw = object(value, label);
return {
frameCount: exact(raw.frame_count, 4489, `${label} frames`),
routeDurationSeconds: number(raw.route_duration_seconds, `${label} duration`),
detectionObservationCount: integer(raw.detection_observation_count, `${label} detections`),
trackObservationCount: integer(raw.track_observation_count, `${label} track observations`),
detectionBoxClippedCount: integer(raw.detection_box_clipped_count, `${label} clipped detections`),
trackBoxClippedCount: integer(raw.track_box_clipped_count, `${label} clipped tracks`),
uniqueTrackCount: integer(raw.unique_track_count, `${label} tracks`),
meanTrackedObjectsPerFrame: number(raw.mean_tracked_objects_per_frame, `${label} mean objects`),
zeroDetectionFrameCount: integer(raw.zero_detection_frame_count, `${label} zero detections`),
zeroTrackFrameCount: integer(raw.zero_track_frame_count, `${label} zero tracks`),
trackerRecoveredFrameCount: integer(raw.tracker_recovered_frame_count, `${label} recovered`),
fullLayerBlackoutEventCount: integer(raw.full_layer_blackout_event_count, `${label} blackouts`),
routeIdGapEventCount: integer(raw.route_id_gap_event_count, `${label} ID gaps`),
shortTrackCount: integer(raw.short_track_count, `${label} short tracks`),
shortTrackFraction: number(raw.short_track_fraction, `${label} short fraction`),
trackClassSwitchCount: integer(raw.track_class_switch_count, `${label} class switches`),
};
}
function method(value: unknown): E46ELaboratoryMethod {
const raw = object(value, "E46F method");
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46F method schema");
exact(raw.completeness, "complete", "E46F method completeness");
exact(raw.execution_class, "hybrid", "E46F execution class");
const components = array(raw.components, "E46F method components").map((entry) => {
const component = object(entry, "E46F method component");
const kind = string(component.kind, "E46F component kind");
if (!(["source", "tool", "model", "algorithm", "runtime"] as const).includes(
kind as "source",
)) {
throw new ContractError("E46F component kind: неизвестное значение.");
}
return {
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
name: string(component.name, "E46F component name"),
version: string(component.version, "E46F component version"),
role: string(component.role, "E46F component role"),
identitySha256: sha(component.identity_sha256, "E46F component identity"),
};
});
return {
completeness: "complete",
executionClass: "hybrid",
pipelineId: string(raw.pipeline_id, "E46F pipeline"),
components,
};
}
function classObservations(value: unknown, label: string): Readonly<Record<string, number>> {
const raw = object(value, label);
return Object.fromEntries(
Object.entries(raw).map(([key, entry]) => [key, integer(entry, `${label}.${key}`)]),
);
}
function triage(value: unknown, label: string): E46FLargeBoxTriage {
const raw = object(value, label);
return {
observationCount: integer(raw.observation_count, `${label} observations`),
frameCount: integer(raw.frame_count, `${label} frames`),
trackIdCount: integer(raw.track_id_count, `${label} tracks`),
classObservations: classObservations(raw.class_observations, `${label} classes`),
};
}
export async function fetchE46FDashCamBakeoff({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<E46FDashCamBakeoffResult | null> {
const response = await fetcher("/api/v1/laboratory/e46f/results?limit=1", {
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) throw new ContractError(`E46F LAB недоступен: HTTP ${response.status}.`);
const catalog = object(await response.json(), "E46F catalog");
exact(catalog.schema_version, "missioncore.e46f-dashcam-bakeoff-catalog/v1", "E46F catalog schema");
const items = array(catalog.items, "E46F results");
if (!items.length) return null;
if (items.length !== 1) throw new ContractError("E46F catalog size: нарушен контракт.");
const item = object(items[0], "E46F result");
exact(item.schema_version, "missioncore.e46f-dashcam-bakeoff-view/v1", "E46F view schema");
exact(item.ground_truth, false, "E46F truth authority");
const resultId = string(item.result_id, "E46F result id");
if (!/^e46f-dashcam-bakeoff-[a-f0-9]{64}$/.test(resultId)) {
throw new ContractError("E46F result identity: нарушен контракт.");
}
const acceptance = object(item.acceptance, "E46F acceptance");
const comparison = object(item.comparison, "E46F comparison");
exact(comparison.controlled_change, "detector-only", "E46F controlled change");
const delta = object(comparison.delta, "E46F delta");
const largeBox = object(comparison.large_box_visual_triage, "E46F large-box triage");
const visualReview = object(comparison.visual_review, "E46F visual review");
const video = object(item.video, "E46F video");
const baselineResultId = string(comparison.baseline_result_id, "E46F baseline id");
if (!/^e46e-ready-stack-[a-f0-9]{64}$/.test(baselineResultId)) {
throw new ContractError("E46F baseline identity: нарушен контракт.");
}
return {
resultId,
createdAtUtc: string(item.created_at_utc, "E46F created"),
sourceSessionId: string(item.source_session_id, "E46F source session"),
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46F camera"),
metrics: metrics(item.metrics, "E46F metrics"),
acceptance: {
fullRouteAccounted: exact(acceptance.full_route_accounted, true, "E46F full route"),
stockDetectorTrackerExecuted: exact(acceptance.stock_detector_tracker_executed, true, "E46F stack"),
controlledDetectorOnlyChange: exact(acceptance.controlled_detector_only_change, true, "E46F controlled change"),
visualOverlayAvailable: exact(acceptance.visual_overlay_available, true, "E46F visual"),
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46F truth"),
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46F safety"),
},
method: method(item.method),
limitations: array(item.limitations, "E46F limitations").map((entry) => string(entry, "E46F limitation")),
comparison: {
baselineResultId,
baselineMetrics: metrics(comparison.baseline_metrics, "E46E baseline metrics"),
delta: {
zeroTrackFrameCount: signedNumber(delta.zero_track_frame_count, "E46F zero-track delta"),
fullLayerBlackoutEventCount: signedNumber(delta.full_layer_blackout_event_count, "E46F blackout delta"),
uniqueTrackCount: signedNumber(delta.unique_track_count, "E46F track delta"),
shortTrackFraction: signedNumber(delta.short_track_fraction, "E46F short-track delta"),
},
largeBoxVisualTriage: {
areaRatioThreshold: exact(largeBox.area_ratio_threshold, 0.2, "E46F triage threshold"),
candidate: triage(largeBox.candidate, "E46F candidate triage"),
baseline: triage(largeBox.baseline, "E46E baseline triage"),
interpretation: string(largeBox.interpretation, "E46F triage interpretation"),
},
visualReview: {
status: exact(visualReview.status, "rejected-semantic-regression", "E46F visual status"),
sampleVideoSeconds: array(visualReview.sample_video_seconds, "E46F review timestamps")
.map((entry) => number(entry, "E46F review timestamp")),
finding: string(visualReview.finding, "E46F visual finding"),
nextAction: string(visualReview.next_action, "E46F next action"),
},
verdict: exact(comparison.verdict, "reject-dashcamnet-on-unrectified-fisheye", "E46F verdict"),
},
video: {
url: string(video.url, "E46F video URL"),
mediaType: exact(video.media_type, "video/mp4", "E46F media type"),
byteLength: integer(video.byte_length, "E46F video bytes"),
sha256: sha(video.sha256, "E46F video SHA"),
width: exact(video.width, 800, "E46F video width"),
height: exact(video.height, 600, "E46F video height"),
},
};
}
@@ -0,0 +1,327 @@
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
export type E46GCandidate = "trafficcamnet" | "dashcamnet";
export type E46GView = "left" | "front" | "right";
export interface E46GViewMetrics {
frameCount: 600;
detectionObservationCount: number;
trackObservationCount: number;
uniqueTrackCount: number;
meanTrackedObjectsPerFrame: number;
zeroDetectionFrameCount: number;
zeroTrackFrameCount: number;
fullLayerBlackoutEventCount: number;
shortTrackFraction: number;
largeTrackObservationCount: number;
largeTrackFraction: number;
}
export interface E46GCandidateMetrics {
sourceFrameCount: 600;
viewFrameCount: 1800;
detectionObservationCount: number;
trackObservationCount: number;
uniqueTrackCount: number;
largeTrackObservationCount: number;
largeTrackFraction: number;
views: Readonly<Record<E46GView, E46GViewMetrics>>;
}
export interface E46GVideo {
url: string;
mediaType: "video/mp4";
byteLength: number;
sha256: string;
width: 2880;
height: 544;
durationSeconds: 60;
viewOrder: readonly ["left", "front", "right"];
}
export interface E46GRectifiedDetectorBakeoffResult {
resultId: string;
createdAtUtc: string;
sourceSessionId: string;
cameraSourceId: "sensor.camera.right";
selection: {
firstSourceFrameIndex: 1000;
lastSourceFrameIndex: 1599;
frameCount: 600;
};
rectification: {
provider: "NVIDIA Gst-nvdewarper";
providerVersion: string;
outputResolution: readonly [960, 544];
horizontalFovDegrees: 100;
retainedSourceFrameIndexRange: readonly [0, 4487];
excludedSourceTailFrameCount: 1;
viewOrder: readonly ["left", "front", "right"];
};
metrics: Readonly<Record<E46GCandidate, E46GCandidateMetrics>>;
acceptance: {
exactRecordedRightSourceBound: true;
factoryCalibrationBound: true;
officialNvidiaDewarperExecuted: true;
stockDetectorTrackerExecuted: true;
sameViewsAndFramesForBothCandidates: true;
visualComparisonVideosAvailable: true;
independentTruthAvailable: false;
candidateAccepted: false;
navigationOrSafetyAccepted: false;
};
method: E46ELaboratoryMethod;
limitations: readonly string[];
comparison: {
visualReview: {
status: "selected-for-next-diagnostic";
reviewedVideoSeconds: readonly number[];
selectedCandidate: "trafficcamnet";
selectedView: "front";
excludedViews: readonly ["left", "right"];
finding: string;
risk: string;
nextAction: string;
};
verdict: "select-trafficcamnet-front-only-for-e46h";
};
videos: Readonly<Record<E46GCandidate, E46GVideo>>;
}
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
class ContractError extends Error {}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ContractError(`${label}: ожидалась строка.`);
}
return value;
}
function number(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new ContractError(`${label}: ожидалось число.`);
}
return value;
}
function integer(value: unknown, label: string): number {
const parsed = number(value, label);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
}
return parsed;
}
function exact<T extends string | number | boolean>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
return expected;
}
function sha(value: unknown, label: string): string {
const parsed = string(value, label);
if (!/^[a-f0-9]{64}$/.test(parsed)) {
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
}
return parsed;
}
function tuple<T extends readonly unknown[]>(
value: unknown,
expected: T,
label: string,
): T {
const parsed = array(value, label);
if (parsed.length !== expected.length || parsed.some((entry, index) => entry !== expected[index])) {
throw new ContractError(`${label}: нарушен контракт.`);
}
return expected;
}
function viewMetrics(value: unknown, label: string): E46GViewMetrics {
const raw = object(value, label);
return {
frameCount: exact(raw.frame_count, 600, `${label} frames`),
detectionObservationCount: integer(raw.detection_observation_count, `${label} detections`),
trackObservationCount: integer(raw.track_observation_count, `${label} tracks`),
uniqueTrackCount: integer(raw.unique_track_count, `${label} identities`),
meanTrackedObjectsPerFrame: number(raw.mean_tracked_objects_per_frame, `${label} mean`),
zeroDetectionFrameCount: integer(raw.zero_detection_frame_count, `${label} zero detections`),
zeroTrackFrameCount: integer(raw.zero_track_frame_count, `${label} zero tracks`),
fullLayerBlackoutEventCount: integer(raw.full_layer_blackout_event_count, `${label} blackouts`),
shortTrackFraction: number(raw.short_track_fraction, `${label} short fraction`),
largeTrackObservationCount: integer(raw.large_track_observation_count, `${label} large tracks`),
largeTrackFraction: number(raw.large_track_fraction, `${label} large fraction`),
};
}
function candidateMetrics(value: unknown, label: string): E46GCandidateMetrics {
const raw = object(value, label);
const views = object(raw.views, `${label} views`);
return {
sourceFrameCount: exact(raw.source_frame_count, 600, `${label} source frames`),
viewFrameCount: exact(raw.view_frame_count, 1800, `${label} view frames`),
detectionObservationCount: integer(raw.detection_observation_count, `${label} detections`),
trackObservationCount: integer(raw.track_observation_count, `${label} tracks`),
uniqueTrackCount: integer(raw.unique_track_count, `${label} identities`),
largeTrackObservationCount: integer(raw.large_track_observation_count, `${label} large tracks`),
largeTrackFraction: number(raw.large_track_fraction, `${label} large fraction`),
views: {
left: viewMetrics(views.left, `${label} left`),
front: viewMetrics(views.front, `${label} front`),
right: viewMetrics(views.right, `${label} right`),
},
};
}
function method(value: unknown): E46ELaboratoryMethod {
const raw = object(value, "E46G method");
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46G method schema");
exact(raw.completeness, "complete", "E46G method completeness");
exact(raw.execution_class, "hybrid", "E46G execution class");
const components = array(raw.components, "E46G method components").map((entry) => {
const component = object(entry, "E46G method component");
const kind = string(component.kind, "E46G component kind");
if (!("source tool model algorithm runtime".split(" ")).includes(kind)) {
throw new ContractError("E46G component kind: неизвестное значение.");
}
return {
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
name: string(component.name, "E46G component name"),
version: string(component.version, "E46G component version"),
role: string(component.role, "E46G component role"),
identitySha256: sha(component.identity_sha256, "E46G component identity"),
};
});
return {
completeness: "complete",
executionClass: "hybrid",
pipelineId: string(raw.pipeline_id, "E46G pipeline"),
components,
};
}
function video(value: unknown, label: string): E46GVideo {
const raw = object(value, label);
return {
url: string(raw.url, `${label} URL`),
mediaType: exact(raw.media_type, "video/mp4", `${label} media`),
byteLength: integer(raw.byte_length, `${label} bytes`),
sha256: sha(raw.sha256, `${label} SHA`),
width: exact(raw.width, 2880, `${label} width`),
height: exact(raw.height, 544, `${label} height`),
durationSeconds: exact(raw.duration_seconds, 60, `${label} duration`),
viewOrder: tuple(raw.view_order, ["left", "front", "right"] as const, `${label} views`),
};
}
export async function fetchE46GRectifiedDetectorBakeoff({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<E46GRectifiedDetectorBakeoffResult | null> {
const response = await fetcher("/api/v1/laboratory/e46g/results?limit=1", {
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) throw new ContractError(`E46G LAB недоступен: HTTP ${response.status}.`);
const catalog = object(await response.json(), "E46G catalog");
exact(catalog.schema_version, "missioncore.e46g-rectified-detector-bakeoff-catalog/v1", "E46G catalog schema");
const items = array(catalog.items, "E46G results");
if (!items.length) return null;
if (items.length !== 1) throw new ContractError("E46G catalog size: нарушен контракт.");
const item = object(items[0], "E46G result");
exact(item.schema_version, "missioncore.e46g-rectified-detector-bakeoff-view/v1", "E46G view schema");
exact(item.status, "selected-for-next-diagnostic-full-route", "E46G status");
exact(item.ground_truth, false, "E46G truth authority");
const resultId = string(item.result_id, "E46G result id");
if (!/^e46g-rectified-detector-bakeoff-[a-f0-9]{64}$/.test(resultId)) {
throw new ContractError("E46G result identity: нарушен контракт.");
}
const selection = object(item.selection, "E46G selection");
const rectification = object(item.rectification, "E46G rectification");
const metrics = object(item.metrics, "E46G metrics");
const acceptance = object(item.acceptance, "E46G acceptance");
const comparison = object(item.comparison, "E46G comparison");
const review = object(comparison.visual_review, "E46G visual review");
const videos = object(item.videos, "E46G videos");
const authority = object(item.authority, "E46G authority");
exact(authority.ground_truth, false, "E46G authority ground truth");
exact(authority.independent_truth, false, "E46G authority independent truth");
exact(authority.candidate_accepted, false, "E46G authority candidate");
exact(authority.commands_enabled, false, "E46G authority commands");
exact(authority.navigation_or_safety_accepted, false, "E46G authority safety");
return {
resultId,
createdAtUtc: string(item.created_at_utc, "E46G created"),
sourceSessionId: string(item.source_session_id, "E46G source session"),
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46G camera"),
selection: {
firstSourceFrameIndex: exact(selection.first_source_frame_index, 1000, "E46G first frame"),
lastSourceFrameIndex: exact(selection.last_source_frame_index, 1599, "E46G last frame"),
frameCount: exact(selection.frame_count, 600, "E46G frame count"),
},
rectification: {
provider: exact(rectification.provider, "NVIDIA Gst-nvdewarper", "E46G dewarper"),
providerVersion: string(rectification.provider_version, "E46G dewarper version"),
outputResolution: tuple(rectification.output_resolution, [960, 544] as const, "E46G output"),
horizontalFovDegrees: exact(rectification.horizontal_fov_degrees, 100, "E46G FOV"),
retainedSourceFrameIndexRange: tuple(rectification.retained_source_frame_index_range, [0, 4487] as const, "E46G retained source"),
excludedSourceTailFrameCount: exact(rectification.excluded_source_tail_frame_count, 1, "E46G excluded tail"),
viewOrder: tuple(rectification.view_order, ["left", "front", "right"] as const, "E46G views"),
},
metrics: {
trafficcamnet: candidateMetrics(metrics.trafficcamnet, "TrafficCamNet"),
dashcamnet: candidateMetrics(metrics.dashcamnet, "DashCamNet"),
},
acceptance: {
exactRecordedRightSourceBound: exact(acceptance.exact_recorded_right_source_bound, true, "E46G source"),
factoryCalibrationBound: exact(acceptance.factory_calibration_bound, true, "E46G calibration"),
officialNvidiaDewarperExecuted: exact(acceptance.official_nvidia_dewarper_executed, true, "E46G dewarper execution"),
stockDetectorTrackerExecuted: exact(acceptance.stock_detector_tracker_executed, true, "E46G stock stack"),
sameViewsAndFramesForBothCandidates: exact(acceptance.same_views_and_frames_for_both_candidates, true, "E46G controlled A/B"),
visualComparisonVideosAvailable: exact(acceptance.visual_comparison_videos_available, true, "E46G videos"),
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46G truth"),
candidateAccepted: exact(acceptance.candidate_accepted, false, "E46G acceptance authority"),
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46G safety"),
},
method: method(item.method),
limitations: array(item.limitations, "E46G limitations").map((entry) => string(entry, "E46G limitation")),
comparison: {
visualReview: {
status: exact(review.status, "selected-for-next-diagnostic", "E46G review status"),
reviewedVideoSeconds: array(review.reviewed_video_seconds, "E46G review seconds").map((entry) => number(entry, "E46G review second")),
selectedCandidate: exact(review.selected_candidate, "trafficcamnet", "E46G candidate"),
selectedView: exact(review.selected_view, "front", "E46G selected view"),
excludedViews: tuple(review.excluded_views, ["left", "right"] as const, "E46G excluded views"),
finding: string(review.finding, "E46G finding"),
risk: string(review.risk, "E46G risk"),
nextAction: string(review.next_action, "E46G next action"),
},
verdict: exact(comparison.verdict, "select-trafficcamnet-front-only-for-e46h", "E46G verdict"),
},
videos: {
trafficcamnet: video(videos.trafficcamnet, "TrafficCamNet video"),
dashcamnet: video(videos.dashcamnet, "DashCamNet video"),
},
};
}
@@ -0,0 +1,325 @@
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
export interface E46HReviewWindow {
id: string;
label: string;
startSeconds: number;
endSeconds: number;
sourceTrackId: number | null;
verdict: "semantic-false-positive" | "empty-scene-expected";
}
export interface E46HFullRectifiedFrontReplayResult {
resultId: string;
createdAtUtc: string;
sourceSessionId: string;
cameraSourceId: "sensor.camera.right";
baselineResultId: string;
selection: {
firstSourceFrameIndex: 0;
lastSourceFrameIndex: 4487;
frameCount: 4488;
excludedSourceTailFrameCount: 1;
};
rectification: {
provider: "NVIDIA Gst-nvdewarper";
providerVersion: string;
projection: "fisheye-to-perspective";
view: "front";
outputResolution: readonly [960, 544];
horizontalFovDegrees: 100;
};
metrics: {
frameCount: 4488;
routeDurationSeconds: number;
detectionObservationCount: number;
trackObservationCount: number;
uniqueTrackCount: number;
meanTrackedObjectsPerFrame: number;
zeroDetectionFrameCount: number;
zeroTrackFrameCount: number;
trackerRecoveredFrameCount: number;
fullLayerBlackoutEventCount: number;
routeIdGapEventCount: number;
shortTrackCount: number;
shortTrackFraction: number;
trackClassSwitchCount: number;
largeTrackObservationCount: number;
largeTrackFraction: number;
};
acceptance: {
exactRecordedRightSourceBound: true;
factoryCalibrationBound: true;
officialNvidiaDewarperExecuted: true;
selectedStockDetectorTrackerExecuted: true;
retainedRouteAccounted: true;
terminalSourceFrameExcluded: true;
fullVisualReviewCompleted: false;
independentTruthAvailable: false;
candidateAccepted: false;
navigationOrSafetyAccepted: false;
};
decision: {
selectedProvider: "front-trafficcamnet-stock-nvdcf";
customDetectorOrTrackerLogicUsed: false;
providerPromoted: false;
nextAction: string;
};
method: E46ELaboratoryMethod;
limitations: readonly string[];
visualReview: {
status: "full-continuous-and-targeted-review-completed";
completeVideoReviewed: true;
reviewedVideoRangeSeconds: readonly [0, 448.8];
verdict: "useful-front-continuity-but-semantic-regression-blocks-promotion";
reviewWindows: readonly E46HReviewWindow[];
finding: string;
blackoutInterpretation: string;
nextAction: string;
};
video: {
url: string;
mediaType: "video/mp4";
byteLength: number;
sha256: string;
width: 960;
height: 544;
durationSeconds: 448.8;
};
}
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
class ContractError extends Error {}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ContractError(`${label}: ожидалась строка.`);
}
return value;
}
function number(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new ContractError(`${label}: ожидалось число.`);
}
return value;
}
function integer(value: unknown, label: string): number {
const parsed = number(value, label);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
}
return parsed;
}
function exact<T extends string | number | boolean>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
return expected;
}
function sha(value: unknown, label: string): string {
const parsed = string(value, label);
if (!/^[a-f0-9]{64}$/.test(parsed)) {
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
}
return parsed;
}
function method(value: unknown): E46ELaboratoryMethod {
const raw = object(value, "E46H method");
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46H method schema");
exact(raw.completeness, "complete", "E46H method completeness");
exact(raw.execution_class, "hybrid", "E46H execution class");
const components = array(raw.components, "E46H method components").map((entry) => {
const component = object(entry, "E46H method component");
const kind = string(component.kind, "E46H component kind");
if (!("source tool model algorithm runtime".split(" ")).includes(kind)) {
throw new ContractError("E46H component kind: неизвестное значение.");
}
return {
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
name: string(component.name, "E46H component name"),
version: string(component.version, "E46H component version"),
role: string(component.role, "E46H component role"),
identitySha256: sha(component.identity_sha256, "E46H component identity"),
};
});
return {
completeness: "complete",
executionClass: "hybrid",
pipelineId: string(raw.pipeline_id, "E46H pipeline"),
components,
};
}
function reviewWindow(value: unknown): E46HReviewWindow {
const raw = object(value, "E46H review window");
const sourceTrackId = raw.source_track_id === null
? null
: integer(raw.source_track_id, "E46H review track");
const verdict = raw.verdict;
if (verdict !== "semantic-false-positive" && verdict !== "empty-scene-expected") {
throw new ContractError("E46H review verdict: неизвестное значение.");
}
const startSeconds = number(raw.start_seconds, "E46H review start");
const endSeconds = number(raw.end_seconds, "E46H review end");
if (startSeconds < 0 || endSeconds <= startSeconds || endSeconds > 448.8) {
throw new ContractError("E46H review window: нарушен диапазон.");
}
return {
id: string(raw.id, "E46H review id"),
label: string(raw.label, "E46H review label"),
startSeconds,
endSeconds,
sourceTrackId,
verdict,
};
}
export async function fetchE46HFullRectifiedFrontReplay({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<E46HFullRectifiedFrontReplayResult | null> {
const response = await fetcher("/api/v1/laboratory/e46h/results?limit=1", {
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) throw new ContractError(`E46H LAB недоступен: HTTP ${response.status}.`);
const catalog = object(await response.json(), "E46H catalog");
exact(catalog.schema_version, "missioncore.e46h-full-rectified-front-replay-catalog/v1", "E46H catalog schema");
const items = array(catalog.items, "E46H results");
if (!items.length) return null;
if (items.length !== 1) throw new ContractError("E46H catalog size: нарушен контракт.");
const item = object(items[0], "E46H result");
exact(item.schema_version, "missioncore.e46h-full-rectified-front-replay-view/v1", "E46H view schema");
exact(item.status, "diagnostic-regression-large-semantic-false-tracks", "E46H status");
exact(item.ground_truth, false, "E46H truth authority");
const resultId = string(item.result_id, "E46H result id");
if (!/^e46h-full-rectified-front-replay-[a-f0-9]{64}$/.test(resultId)) {
throw new ContractError("E46H result identity: нарушен контракт.");
}
const selection = object(item.selection, "E46H selection");
const rectification = object(item.rectification, "E46H rectification");
const metrics = object(item.metrics, "E46H metrics");
const acceptance = object(item.acceptance, "E46H acceptance");
const decision = object(item.decision, "E46H decision");
const visualReview = object(item.visual_review, "E46H visual review");
const video = object(item.video, "E46H video");
const authority = object(item.authority, "E46H authority");
exact(authority.ground_truth, false, "E46H authority ground truth");
exact(authority.independent_truth, false, "E46H authority independent truth");
exact(authority.candidate_accepted, false, "E46H authority candidate");
exact(authority.commands_enabled, false, "E46H authority commands");
exact(authority.navigation_or_safety_accepted, false, "E46H authority safety");
return {
resultId,
createdAtUtc: string(item.created_at_utc, "E46H created"),
sourceSessionId: string(item.source_session_id, "E46H source session"),
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46H camera"),
baselineResultId: string(item.baseline_result_id, "E46H baseline"),
selection: {
firstSourceFrameIndex: exact(selection.first_source_frame_index, 0, "E46H first frame"),
lastSourceFrameIndex: exact(selection.last_source_frame_index, 4487, "E46H last frame"),
frameCount: exact(selection.frame_count, 4488, "E46H frame count"),
excludedSourceTailFrameCount: exact(selection.excluded_source_tail_frame_count, 1, "E46H excluded tail"),
},
rectification: {
provider: exact(rectification.provider, "NVIDIA Gst-nvdewarper", "E46H dewarper"),
providerVersion: string(rectification.provider_version, "E46H dewarper version"),
projection: exact(rectification.projection, "fisheye-to-perspective", "E46H projection"),
view: exact(rectification.view, "front", "E46H view"),
outputResolution: (() => {
const parsed = array(rectification.output_resolution, "E46H output");
if (parsed.length !== 2 || parsed[0] !== 960 || parsed[1] !== 544) {
throw new ContractError("E46H output: нарушен контракт.");
}
return [960, 544] as const;
})(),
horizontalFovDegrees: exact(rectification.horizontal_fov_degrees, 100, "E46H FOV"),
},
metrics: {
frameCount: exact(metrics.frame_count, 4488, "E46H metric frames"),
routeDurationSeconds: number(metrics.route_duration_seconds, "E46H source duration"),
detectionObservationCount: integer(metrics.detection_observation_count, "E46H detections"),
trackObservationCount: integer(metrics.track_observation_count, "E46H tracks"),
uniqueTrackCount: integer(metrics.unique_track_count, "E46H identities"),
meanTrackedObjectsPerFrame: number(metrics.mean_tracked_objects_per_frame, "E46H mean"),
zeroDetectionFrameCount: integer(metrics.zero_detection_frame_count, "E46H zero detections"),
zeroTrackFrameCount: integer(metrics.zero_track_frame_count, "E46H zero tracks"),
trackerRecoveredFrameCount: integer(metrics.tracker_recovered_frame_count, "E46H recovered"),
fullLayerBlackoutEventCount: integer(metrics.full_layer_blackout_event_count, "E46H blackouts"),
routeIdGapEventCount: integer(metrics.route_id_gap_event_count, "E46H id gaps"),
shortTrackCount: integer(metrics.short_track_count, "E46H short tracks"),
shortTrackFraction: number(metrics.short_track_fraction, "E46H short fraction"),
trackClassSwitchCount: integer(metrics.track_class_switch_count, "E46H class switches"),
largeTrackObservationCount: integer(metrics.large_track_observation_count, "E46H large tracks"),
largeTrackFraction: number(metrics.large_track_fraction, "E46H large fraction"),
},
acceptance: {
exactRecordedRightSourceBound: exact(acceptance.exact_recorded_right_source_bound, true, "E46H source"),
factoryCalibrationBound: exact(acceptance.factory_calibration_bound, true, "E46H calibration"),
officialNvidiaDewarperExecuted: exact(acceptance.official_nvidia_dewarper_executed, true, "E46H dewarper execution"),
selectedStockDetectorTrackerExecuted: exact(acceptance.selected_stock_detector_tracker_executed, true, "E46H stock stack"),
retainedRouteAccounted: exact(acceptance.retained_route_accounted, true, "E46H retained route"),
terminalSourceFrameExcluded: exact(acceptance.terminal_source_frame_excluded, true, "E46H tail"),
fullVisualReviewCompleted: exact(acceptance.full_visual_review_completed, false, "E46H visual review authority"),
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46H truth"),
candidateAccepted: exact(acceptance.candidate_accepted, false, "E46H candidate"),
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46H safety"),
},
decision: {
selectedProvider: exact(decision.selected_provider, "front-trafficcamnet-stock-nvdcf", "E46H provider"),
customDetectorOrTrackerLogicUsed: exact(decision.custom_detector_or_tracker_logic_used, false, "E46H custom logic"),
providerPromoted: exact(decision.provider_promoted, false, "E46H promotion"),
nextAction: string(decision.next_action, "E46H decision"),
},
method: method(item.method),
limitations: array(item.limitations, "E46H limitations").map((entry) => string(entry, "E46H limitation")),
visualReview: {
status: exact(visualReview.status, "full-continuous-and-targeted-review-completed", "E46H review status"),
completeVideoReviewed: exact(visualReview.complete_video_reviewed, true, "E46H complete video review"),
reviewedVideoRangeSeconds: (() => {
const parsed = array(visualReview.reviewed_video_range_seconds, "E46H reviewed video range");
if (parsed.length !== 2 || parsed[0] !== 0 || parsed[1] !== 448.8) {
throw new ContractError("E46H reviewed video range: нарушен контракт.");
}
return [0, 448.8] as const;
})(),
verdict: exact(visualReview.verdict, "useful-front-continuity-but-semantic-regression-blocks-promotion", "E46H review verdict"),
reviewWindows: array(visualReview.review_windows, "E46H review windows").map(reviewWindow),
finding: string(visualReview.finding, "E46H finding"),
blackoutInterpretation: string(visualReview.blackout_interpretation, "E46H blackout interpretation"),
nextAction: string(visualReview.next_action, "E46H next action"),
},
video: {
url: string(video.url, "E46H video URL"),
mediaType: exact(video.media_type, "video/mp4", "E46H video media"),
byteLength: integer(video.byte_length, "E46H video bytes"),
sha256: sha(video.sha256, "E46H video SHA"),
width: exact(video.width, 960, "E46H video width"),
height: exact(video.height, 544, "E46H video height"),
durationSeconds: exact(video.duration_seconds, 448.8, "E46H video duration"),
},
};
}
@@ -0,0 +1,350 @@
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
export interface E46IReviewWindow {
id: string;
label: string;
startSeconds: number;
endSeconds: number;
verdict:
| "legacy-background-false-positive-suppressed"
| "empty-scene-mostly-preserved";
}
export interface E46IGroundingDinoFullReplayResult {
resultId: string;
createdAtUtc: string;
baselineResultId: string;
source: {
cameraSourceId: "sensor.camera.right";
sessionId: string;
view: "front";
resolution: readonly [960, 544];
frameCount: 4488;
frameRate: 10;
durationSeconds: 448.8;
};
provider: {
name: "NVIDIA TAO Grounding DINO Swin-Tiny Commercial";
version: "1.0";
toolkit: "NVIDIA TAO Toolkit Deploy 7.0.1";
precision: "FP16";
};
inference: {
captions: readonly ["car", "person", "bicycle", "road sign"];
confidenceThreshold: 0.5;
meanFramesPerSecond: number;
};
metrics: {
frameCount: 4488;
detectionObservationCount: number;
classObservationCounts: Readonly<Record<string, number>>;
meanDetectionsPerFrame: number;
maxDetectionsPerFrame: number;
zeroDetectionFrameCount: number;
longestZeroDetectionRunFrames: number;
confidenceMean: number;
largeBoxObservationCount: number;
largeBoxObservationFraction: number;
workerElapsedSeconds: number;
workerMeanFramesPerSecond: number;
};
shadowGate: {
legacyCases: 5;
suppressedCases: 5;
positiveCases: 5;
positiveCasesWithRelevantDetection: 5;
limitations: readonly string[];
};
visualReview: {
completeVideoPlaybackCompleted: true;
reviewedRangeSeconds: readonly [0, 448.8];
verdict: "material-semantic-progress-not-yet-complete-perception";
reviewWindows: readonly E46IReviewWindow[];
finding: string;
knownError: string;
};
decision: {
selectedProvider: "nvidia-grounding-dino-swin-tiny-commercial-v1.0";
providerSemanticProgress: true;
providerPromoted: false;
nextAction: string;
};
method: E46ELaboratoryMethod;
limitations: readonly string[];
video: {
url: string;
mediaType: "video/mp4";
byteLength: number;
sha256: string;
width: 960;
height: 544;
frameRate: 10;
frameCount: 4488;
durationSeconds: 448.8;
};
visuals: {
shadowGate: VisualArtifact;
fullRoute: VisualArtifact;
targetedWindows: VisualArtifact;
};
}
interface VisualArtifact {
url: string;
mediaType: "image/png";
byteLength: number;
sha256: string;
}
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
class ContractError extends Error {}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
return value;
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ContractError(`${label}: ожидалась строка.`);
}
return value;
}
function number(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new ContractError(`${label}: ожидалось число.`);
}
return value;
}
function integer(value: unknown, label: string): number {
const parsed = number(value, label);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
}
return parsed;
}
function exact<T extends string | number | boolean>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
return expected;
}
function sha(value: unknown, label: string): string {
const parsed = text(value, label);
if (!/^[a-f0-9]{64}$/.test(parsed)) {
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
}
return parsed;
}
function pair<const T extends readonly [number, number]>(
value: unknown,
expected: T,
label: string,
): T {
const parsed = array(value, label);
if (parsed.length !== 2 || parsed[0] !== expected[0] || parsed[1] !== expected[1]) {
throw new ContractError(`${label}: нарушен диапазон.`);
}
return expected;
}
function method(value: unknown): E46ELaboratoryMethod {
const raw = object(value, "E46I method");
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46I method schema");
exact(raw.completeness, "complete", "E46I method completeness");
exact(raw.execution_class, "hybrid", "E46I method execution");
return {
completeness: "complete",
executionClass: "hybrid",
pipelineId: text(raw.pipeline_id, "E46I pipeline"),
components: array(raw.components, "E46I components").map((entry) => {
const component = object(entry, "E46I component");
const kind = text(component.kind, "E46I component kind");
if (!("source tool model algorithm runtime".split(" ")).includes(kind)) {
throw new ContractError("E46I component kind: неизвестное значение.");
}
return {
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
name: text(component.name, "E46I component name"),
version: text(component.version, "E46I component version"),
role: text(component.role, "E46I component role"),
identitySha256: sha(component.identity_sha256, "E46I component identity"),
};
}),
};
}
function visual(value: unknown, label: string): VisualArtifact {
const raw = object(value, label);
return {
url: text(raw.url, `${label} URL`),
mediaType: exact(raw.media_type, "image/png", `${label} media`),
byteLength: integer(raw.byte_length, `${label} bytes`),
sha256: sha(raw.sha256, `${label} SHA`),
};
}
function reviewWindow(value: unknown): E46IReviewWindow {
const raw = object(value, "E46I review window");
const verdict = raw.verdict;
if (
verdict !== "legacy-background-false-positive-suppressed"
&& verdict !== "empty-scene-mostly-preserved"
) {
throw new ContractError("E46I review verdict: неизвестное значение.");
}
const startSeconds = number(raw.start_seconds, "E46I review start");
const endSeconds = number(raw.end_seconds, "E46I review end");
if (startSeconds < 0 || endSeconds <= startSeconds || endSeconds > 448.8) {
throw new ContractError("E46I review window: нарушен диапазон.");
}
return {
id: text(raw.id, "E46I review id"),
label: text(raw.label, "E46I review label"),
startSeconds,
endSeconds,
verdict,
};
}
export async function fetchE46IGroundingDinoFullReplay({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<E46IGroundingDinoFullReplayResult | null> {
const response = await fetcher("/api/v1/laboratory/e46i/results?limit=1", {
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) throw new ContractError(`E46I LAB недоступен: HTTP ${response.status}.`);
const catalog = object(await response.json(), "E46I catalog");
exact(catalog.schema_version, "missioncore.e46i-grounding-dino-full-replay-catalog/v1", "E46I catalog schema");
const items = array(catalog.items, "E46I results");
if (!items.length) return null;
if (items.length !== 1) throw new ContractError("E46I catalog size: нарушен контракт.");
const item = object(items[0], "E46I result");
exact(item.schema_version, "missioncore.e46i-grounding-dino-full-replay-view/v1", "E46I view schema");
exact(item.status, "semantic-regression-suppressed-awaiting-temporal-layer", "E46I status");
exact(item.ground_truth, false, "E46I truth authority");
const authority = object(item.authority, "E46I authority");
exact(authority.ground_truth, false, "E46I authority truth");
exact(authority.independent_truth, false, "E46I independent truth");
exact(authority.candidate_accepted, false, "E46I acceptance");
exact(authority.commands_enabled, false, "E46I commands");
exact(authority.navigation_or_safety_accepted, false, "E46I safety");
const resultId = text(item.result_id, "E46I result id");
if (!/^e46i-grounding-dino-full-replay-[a-f0-9]{64}$/.test(resultId)) {
throw new ContractError("E46I result identity: нарушен контракт.");
}
const source = object(item.source, "E46I source");
const provider = object(item.provider, "E46I provider");
const inference = object(item.inference, "E46I inference");
const metrics = object(item.metrics, "E46I metrics");
const shadow = object(item.shadow_gate, "E46I shadow gate");
const review = object(item.visual_review, "E46I visual review");
const decision = object(item.decision, "E46I decision");
const video = object(item.video, "E46I video");
const visuals = object(item.visuals, "E46I visuals");
const captions = array(inference.captions, "E46I captions");
if (captions.join("|") !== "car|person|bicycle|road sign") {
throw new ContractError("E46I captions: нарушен контракт.");
}
const classCounts = object(metrics.class_observation_counts, "E46I class counts");
return {
resultId,
createdAtUtc: text(item.created_at_utc, "E46I created"),
baselineResultId: text(item.baseline_result_id, "E46I baseline"),
source: {
cameraSourceId: exact(source.camera_source_id, "sensor.camera.right", "E46I camera"),
sessionId: text(source.session_id, "E46I session"),
view: exact(source.view, "front", "E46I view"),
resolution: pair(source.projection_resolution, [960, 544], "E46I resolution"),
frameCount: exact(source.video_frame_count, 4488, "E46I frames"),
frameRate: exact(source.video_frame_rate, 10, "E46I fps"),
durationSeconds: exact(source.video_duration_seconds, 448.8, "E46I duration"),
},
provider: {
name: exact(provider.name, "NVIDIA TAO Grounding DINO Swin-Tiny Commercial", "E46I provider"),
version: exact(provider.version, "1.0", "E46I provider version"),
toolkit: exact(provider.deployment_toolkit, "NVIDIA TAO Toolkit Deploy 7.0.1", "E46I toolkit"),
precision: exact(provider.engine_precision, "FP16", "E46I precision"),
},
inference: {
captions: ["car", "person", "bicycle", "road sign"],
confidenceThreshold: exact(inference.confidence_threshold, 0.5, "E46I threshold"),
meanFramesPerSecond: number(inference.mean_frames_per_second, "E46I inference fps"),
},
metrics: {
frameCount: exact(metrics.frame_count, 4488, "E46I metric frames"),
detectionObservationCount: integer(metrics.detection_observation_count, "E46I detections"),
classObservationCounts: Object.fromEntries(
Object.entries(classCounts).map(([key, value]) => [key, integer(value, `E46I ${key}`)]),
),
meanDetectionsPerFrame: number(metrics.mean_detections_per_frame, "E46I mean"),
maxDetectionsPerFrame: integer(metrics.max_detections_per_frame, "E46I max"),
zeroDetectionFrameCount: integer(metrics.zero_detection_frame_count, "E46I zero"),
longestZeroDetectionRunFrames: integer(metrics.longest_zero_detection_run_frames, "E46I zero run"),
confidenceMean: number(metrics.confidence_mean, "E46I confidence"),
largeBoxObservationCount: integer(metrics.large_box_observation_count, "E46I large boxes"),
largeBoxObservationFraction: number(metrics.large_box_observation_fraction, "E46I large fraction"),
workerElapsedSeconds: number(metrics.worker_elapsed_seconds, "E46I elapsed"),
workerMeanFramesPerSecond: number(metrics.worker_mean_frames_per_second, "E46I worker fps"),
},
shadowGate: {
legacyCases: exact(shadow.legacy_large_false_background_cases, 5, "E46I legacy cases"),
suppressedCases: exact(shadow.legacy_large_false_background_cases_suppressed, 5, "E46I suppressed"),
positiveCases: exact(shadow.positive_anchor_cases, 5, "E46I positives"),
positiveCasesWithRelevantDetection: exact(shadow.positive_anchor_cases_with_relevant_detection, 5, "E46I retained positives"),
limitations: array(shadow.known_semantic_limitations, "E46I gate limitations").map((entry) => text(entry, "E46I gate limitation")),
},
visualReview: {
completeVideoPlaybackCompleted: exact(review.complete_video_playback_completed, true, "E46I playback"),
reviewedRangeSeconds: pair(review.reviewed_video_range_seconds, [0, 448.8], "E46I reviewed range"),
verdict: exact(review.verdict, "material-semantic-progress-not-yet-complete-perception", "E46I verdict"),
reviewWindows: array(review.review_windows, "E46I review windows").map(reviewWindow),
finding: text(review.finding, "E46I finding"),
knownError: text(review.known_error, "E46I known error"),
},
decision: {
selectedProvider: exact(decision.selected_provider, "nvidia-grounding-dino-swin-tiny-commercial-v1.0", "E46I selected provider"),
providerSemanticProgress: exact(decision.provider_semantic_progress, true, "E46I progress"),
providerPromoted: exact(decision.provider_promoted, false, "E46I promotion"),
nextAction: text(decision.next_action, "E46I next action"),
},
method: method(item.method),
limitations: array(item.limitations, "E46I limitations").map((entry) => text(entry, "E46I limitation")),
video: {
url: text(video.url, "E46I video URL"),
mediaType: exact(video.media_type, "video/mp4", "E46I video media"),
byteLength: integer(video.byte_length, "E46I video bytes"),
sha256: sha(video.sha256, "E46I video SHA"),
width: exact(video.width, 960, "E46I video width"),
height: exact(video.height, 544, "E46I video height"),
frameRate: exact(video.frame_rate, 10, "E46I video fps"),
frameCount: exact(video.frame_count, 4488, "E46I video frames"),
durationSeconds: exact(video.duration_seconds, 448.8, "E46I video duration"),
},
visuals: {
shadowGate: visual(visuals.shadow_gate, "E46I shadow visual"),
fullRoute: visual(visuals.full_route, "E46I route visual"),
targetedWindows: visual(visuals.targeted_windows, "E46I targeted visual"),
},
};
}
@@ -0,0 +1,338 @@
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
export interface E46JReviewWindow {
id: string;
label: string;
startSeconds: number;
endSeconds: number;
verdict:
| "legacy-background-false-positive-suppressed"
| "operator-shadow-person-false-positive-observed";
}
interface VisualArtifact {
url: string;
mediaType: "image/png";
byteLength: number;
sha256: string;
}
export interface E46JRawFisheyeRealtimeResult {
resultId: string;
createdAtUtc: string;
source: {
cameraSourceId: "sensor.camera.right";
sessionId: string;
resolution: readonly [800, 600];
frameCount: 4489;
frameRate: number;
durationSeconds: 448.723;
calibrationModel: "KB4";
};
detector: {
architecture: "YOLOX-S";
source: string;
license: "Apache-2.0";
runtime: string;
};
detection: {
minimumScore: 0.5;
nmsIouThreshold: 0.45;
};
metrics: {
frameCount: 4489;
failedFrameCount: 0;
detectionObservationCount: number;
classObservationCounts: Readonly<Record<string, number>>;
meanDetectionsPerFrame: number;
maxDetectionsPerFrame: number;
zeroDetectionFrameCount: number;
longestZeroDetectionRunFrames: number;
coreCapacityFps: number;
corePathMeanMs: number;
corePathP95Ms: number;
inferenceRequestMeanMs: number;
inferenceRequestP95Ms: number;
gpuUtilizationMeanPercent: number;
operatorShadowWindowFrameCount: number;
operatorShadowPersonFrameCount: number;
};
visualReview: {
reviewedRangeSeconds: readonly [0, 448.723];
verdict: "realtime-detector-progress-with-known-shadow-exception";
reviewWindows: readonly E46JReviewWindow[];
finding: string;
knownError: string;
};
decision: {
selectedProvider: "megvii-yolox-s-0.1.1rc0";
realtimeCapacityPassed: true;
readyForTemporalBakeoff: true;
providerPromoted: false;
nextAction: string;
};
method: E46ELaboratoryMethod;
limitations: readonly string[];
video: {
url: string;
mediaType: "video/mp4";
byteLength: number;
sha256: string;
width: 800;
height: 600;
frameRate: number;
frameCount: 4489;
durationSeconds: 448.723;
};
visuals: {
fullRoute: VisualArtifact;
targetedWindows: VisualArtifact;
operatorShadow: VisualArtifact;
};
}
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
class ContractError extends Error {}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
return value;
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ContractError(`${label}: ожидалась строка.`);
}
return value;
}
function number(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new ContractError(`${label}: ожидалось число.`);
}
return value;
}
function integer(value: unknown, label: string): number {
const parsed = number(value, label);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
}
return parsed;
}
function exact<T extends string | number | boolean>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
return expected;
}
function sha(value: unknown, label: string): string {
const parsed = text(value, label);
if (!/^[a-f0-9]{64}$/.test(parsed)) {
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
}
return parsed;
}
function pair<const T extends readonly [number, number]>(
value: unknown,
expected: T,
label: string,
): T {
const parsed = array(value, label);
if (parsed.length !== 2 || parsed[0] !== expected[0] || parsed[1] !== expected[1]) {
throw new ContractError(`${label}: нарушен диапазон.`);
}
return expected;
}
function visual(value: unknown, label: string): VisualArtifact {
const raw = object(value, label);
return {
url: text(raw.url, `${label} URL`),
mediaType: exact(raw.media_type, "image/png", `${label} media`),
byteLength: integer(raw.byte_length, `${label} bytes`),
sha256: sha(raw.sha256, `${label} SHA`),
};
}
function method(value: unknown): E46ELaboratoryMethod {
const raw = object(value, "E46J method");
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46J method schema");
exact(raw.completeness, "complete", "E46J method completeness");
exact(raw.execution_class, "hybrid", "E46J method execution");
return {
completeness: "complete",
executionClass: "hybrid",
pipelineId: text(raw.pipeline_id, "E46J pipeline"),
components: array(raw.components, "E46J components").map((value) => {
const component = object(value, "E46J component");
const kind = text(component.kind, "E46J component kind");
if (!("source tool model algorithm runtime".split(" ")).includes(kind)) {
throw new ContractError("E46J component kind: неизвестное значение.");
}
return {
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
name: text(component.name, "E46J component name"),
version: text(component.version, "E46J component version"),
role: text(component.role, "E46J component role"),
identitySha256: sha(component.identity_sha256, "E46J component identity"),
};
}),
};
}
function reviewWindow(value: unknown): E46JReviewWindow {
const raw = object(value, "E46J review window");
const verdict = raw.verdict;
if (
verdict !== "legacy-background-false-positive-suppressed"
&& verdict !== "operator-shadow-person-false-positive-observed"
) {
throw new ContractError("E46J review verdict: неизвестное значение.");
}
const startSeconds = number(raw.start_seconds, "E46J review start");
const endSeconds = number(raw.end_seconds, "E46J review end");
if (startSeconds < 0 || endSeconds <= startSeconds || endSeconds > 448.723) {
throw new ContractError("E46J review window: нарушен диапазон.");
}
return {
id: text(raw.id, "E46J review id"),
label: text(raw.label, "E46J review label"),
startSeconds,
endSeconds,
verdict,
};
}
export async function fetchE46JRawFisheyeRealtime({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<E46JRawFisheyeRealtimeResult | null> {
const response = await fetcher("/api/v1/laboratory/e46j/results?limit=1", {
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) throw new ContractError(`E46J LAB недоступен: HTTP ${response.status}.`);
const catalog = object(await response.json(), "E46J catalog");
exact(catalog.schema_version, "missioncore.e46j-raw-fisheye-realtime-catalog/v1", "E46J catalog schema");
const items = array(catalog.items, "E46J results");
if (!items.length) return null;
if (items.length !== 1) throw new ContractError("E46J catalog size: нарушен контракт.");
const item = object(items[0], "E46J result");
exact(item.schema_version, "missioncore.e46j-raw-fisheye-realtime-view/v1", "E46J view schema");
exact(item.status, "realtime-capacity-passed-awaiting-temporal-layer", "E46J status");
exact(item.ground_truth, false, "E46J truth authority");
const authority = object(item.authority, "E46J authority");
exact(authority.ground_truth, false, "E46J authority truth");
exact(authority.provider_promoted, false, "E46J promotion");
exact(authority.commands_enabled, false, "E46J commands");
exact(authority.navigation_or_safety_accepted, false, "E46J safety");
const resultId = text(item.result_id, "E46J result id");
if (!/^e46j-raw-fisheye-realtime-[a-f0-9]{64}$/.test(resultId)) {
throw new ContractError("E46J result identity: нарушен контракт.");
}
const source = object(item.source, "E46J source");
const detector = object(item.detector, "E46J detector");
const detection = object(item.detection, "E46J detection");
const metrics = object(item.metrics, "E46J metrics");
const review = object(item.visual_review, "E46J visual review");
const decision = object(item.decision, "E46J decision");
const video = object(item.video, "E46J video");
const visuals = object(item.visuals, "E46J visuals");
const classCounts = object(metrics.class_observation_counts, "E46J class counts");
const acceptance = object(item.acceptance, "E46J acceptance");
exact(acceptance.ten_hz_capacity_gate_passed, true, "E46J capacity gate");
exact(acceptance.latency_gate_passed, true, "E46J latency gate");
exact(acceptance.full_raw_fisheye_retained, true, "E46J FOV gate");
return {
resultId,
createdAtUtc: text(item.created_at_utc, "E46J created"),
source: {
cameraSourceId: exact(source.camera_source_id, "sensor.camera.right", "E46J camera"),
sessionId: text(source.session_id, "E46J session"),
resolution: pair(source.resolution, [800, 600], "E46J resolution"),
frameCount: exact(source.frame_count, 4489, "E46J frames"),
frameRate: number(source.frame_rate, "E46J fps"),
durationSeconds: 448.723,
calibrationModel: exact(source.calibration_model, "KB4", "E46J calibration"),
},
detector: {
architecture: exact(detector.architecture, "YOLOX-S", "E46J architecture"),
source: text(detector.source, "E46J detector source"),
license: exact(detector.license, "Apache-2.0", "E46J license"),
runtime: text(detector.runtime, "E46J runtime"),
},
detection: {
minimumScore: exact(detection.minimum_score, 0.5, "E46J score"),
nmsIouThreshold: exact(detection.nms_iou_threshold, 0.45, "E46J NMS"),
},
metrics: {
frameCount: exact(metrics.frame_count, 4489, "E46J metric frames"),
failedFrameCount: exact(metrics.failed_frame_count, 0, "E46J failures"),
detectionObservationCount: integer(metrics.detection_observation_count, "E46J detections"),
classObservationCounts: Object.fromEntries(
Object.entries(classCounts).map(([key, value]) => [key, integer(value, `E46J ${key}`)]),
),
meanDetectionsPerFrame: number(metrics.mean_detections_per_frame, "E46J mean"),
maxDetectionsPerFrame: integer(metrics.max_detections_per_frame, "E46J max"),
zeroDetectionFrameCount: integer(metrics.zero_detection_frame_count, "E46J zero"),
longestZeroDetectionRunFrames: integer(metrics.longest_zero_detection_run_frames, "E46J zero run"),
coreCapacityFps: number(metrics.core_capacity_fps, "E46J capacity"),
corePathMeanMs: number(metrics.core_path_mean_ms, "E46J core mean"),
corePathP95Ms: number(metrics.core_path_p95_ms, "E46J core p95"),
inferenceRequestMeanMs: number(metrics.inference_request_mean_ms, "E46J infer mean"),
inferenceRequestP95Ms: number(metrics.inference_request_p95_ms, "E46J infer p95"),
gpuUtilizationMeanPercent: number(metrics.gpu_utilization_mean_percent, "E46J GPU"),
operatorShadowWindowFrameCount: integer(metrics.operator_shadow_window_frame_count, "E46J shadow window"),
operatorShadowPersonFrameCount: integer(metrics.operator_shadow_person_frame_count, "E46J shadow false frames"),
},
visualReview: {
reviewedRangeSeconds: pair(review.reviewed_video_range_seconds, [0, 448.723], "E46J reviewed range"),
verdict: exact(review.verdict, "realtime-detector-progress-with-known-shadow-exception", "E46J verdict"),
reviewWindows: array(review.review_windows, "E46J review windows").map(reviewWindow),
finding: text(review.finding, "E46J finding"),
knownError: text(review.known_error, "E46J known error"),
},
decision: {
selectedProvider: exact(decision.selected_provider, "megvii-yolox-s-0.1.1rc0", "E46J provider"),
realtimeCapacityPassed: exact(decision.realtime_capacity_passed, true, "E46J realtime"),
readyForTemporalBakeoff: exact(decision.ready_for_temporal_bakeoff, true, "E46J temporal readiness"),
providerPromoted: exact(decision.provider_promoted, false, "E46J provider promotion"),
nextAction: text(decision.next_action, "E46J next action"),
},
method: method(item.method),
limitations: array(item.limitations, "E46J limitations").map((value) => text(value, "E46J limitation")),
video: {
url: text(video.url, "E46J video URL"),
mediaType: exact(video.media_type, "video/mp4", "E46J video media"),
byteLength: integer(video.byte_length, "E46J video bytes"),
sha256: sha(video.sha256, "E46J video SHA"),
width: exact(video.width, 800, "E46J video width"),
height: exact(video.height, 600, "E46J video height"),
frameRate: number(video.frame_rate, "E46J video fps"),
frameCount: exact(video.frame_count, 4489, "E46J video frames"),
durationSeconds: exact(video.duration_seconds, 448.723, "E46J video duration"),
},
visuals: {
fullRoute: visual(visuals.full_route, "E46J route visual"),
targetedWindows: visual(visuals.targeted_windows, "E46J targeted visual"),
operatorShadow: visual(visuals.operator_shadow, "E46J shadow visual"),
},
};
}
@@ -0,0 +1,554 @@
export type E47SemanticDisposition = "labeled" | "ambiguous";
export interface E47SemanticClass {
classId: number;
label: string;
disposition: E47SemanticDisposition;
colorRgb: readonly [number, number, number];
}
export interface E47SemanticSlamResult {
resultId: string;
createdAtUtc: string;
status: "diagnostic-semantic-slam-shadow";
profileId: string;
baseM4ResultId: string;
semanticResultId: string;
geometryResultId: string;
sourcePackId: string;
calibrationContentSha256: string;
provider: {
providerId: string;
modelId: string;
modelRevision: string;
modelWeightsSha256: string;
preprocessId: string;
};
temporalBinding: {
semanticToCamera: "exact-sequence-and-session-time";
cameraToLidar: "accepted-e6-nearest-host-arrival-best-effort";
clockBasis: "recorded-host-monotonic-arrival";
maximumLidarCameraDeltaMs: number;
maximumPosePointDeltaMs: number;
physicalSynchronizationProven: false;
};
taxonomy: readonly E47SemanticClass[];
metrics: {
frames: {
total: number;
maskAvailable: number;
sourceAvailable: number;
};
points: {
total: number;
projected: number;
labeled: number;
ambiguous: number;
unprojected: number;
absent: number;
};
observations: {
total: number;
labeled: number;
ambiguous: number;
unprojected: number;
absent: number;
};
runtime: {
elapsedMs: number;
framesPerSecond: number;
};
};
acceptance: {
artifactContractPassed: boolean;
frameAccountingPassed: boolean;
pointAccountingPassed: boolean;
observationBindingPassed: boolean;
temporalBindingPassed: boolean;
independentSemanticTruthPassed: false;
providerPromoted: false;
};
limitations: readonly string[];
}
export interface E47SemanticTimelineFrame {
sequence: number;
sourcePointCount: number;
classIds: readonly number[];
statusCodes: readonly number[];
counts: {
labeled: number;
ambiguous: number;
unprojected: number;
absent: number;
};
}
export interface E47SemanticTimelineChunk {
resultId: string;
startSequence: number;
frameCount: number;
nextSequence: number | null;
frames: readonly E47SemanticTimelineFrame[];
}
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
export class E47SemanticSlamContractError extends Error {}
const object = (value: unknown, label: string): Record<string, unknown> => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new E47SemanticSlamContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
};
const array = (value: unknown, label: string): readonly unknown[] => {
if (!Array.isArray(value)) {
throw new E47SemanticSlamContractError(`${label}: ожидался массив.`);
}
return value;
};
const text = (value: unknown, label: string): string => {
if (typeof value !== "string" || !value.trim()) {
throw new E47SemanticSlamContractError(`${label}: ожидалась строка.`);
}
return value;
};
const finite = (value: unknown, label: string): number => {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new E47SemanticSlamContractError(`${label}: ожидалось число.`);
}
return value;
};
const integer = (value: unknown, label: string): number => {
const parsed = finite(value, label);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new E47SemanticSlamContractError(`${label}: ожидалось неотрицательное целое.`);
}
return parsed;
};
const exact = <T extends string | number | boolean>(
value: unknown,
expected: T,
label: string,
): T => {
if (value !== expected) {
throw new E47SemanticSlamContractError(`${label}: нарушен контракт.`);
}
return expected;
};
function resultId(value: unknown): string {
const parsed = text(value, "E47 result id");
if (!/^e47-semantic-slam-[a-f0-9]{64}$/.test(parsed)) {
throw new E47SemanticSlamContractError("E47 result id: нарушена идентичность.");
}
return parsed;
}
function m4ResultId(value: unknown): string {
const parsed = text(value, "E47 base M4 result id");
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) {
throw new E47SemanticSlamContractError("E47 base M4 result id: нарушена идентичность.");
}
return parsed;
}
function sha256(value: unknown, label: string): string {
const parsed = text(value, label);
if (!/^[a-f0-9]{64}$/.test(parsed)) {
throw new E47SemanticSlamContractError(`${label}: ожидался SHA-256.`);
}
return parsed;
}
function taxonomy(value: unknown): readonly E47SemanticClass[] {
const classes = array(value, "E47 taxonomy").map((raw) => {
const item = object(raw, "E47 semantic class");
const classId = integer(item.class_id, "E47 class id");
if (classId > 255) {
throw new E47SemanticSlamContractError("E47 class id: вышел за uint8.");
}
const disposition = text(item.disposition, "E47 class disposition");
if (disposition !== "labeled" && disposition !== "ambiguous") {
throw new E47SemanticSlamContractError("E47 class disposition: неизвестное значение.");
}
const rgb = array(item.color_rgb, "E47 class color").map(
(channel) => integer(channel, "E47 color channel"),
);
if (rgb.length !== 3 || rgb.some((channel) => channel > 255)) {
throw new E47SemanticSlamContractError("E47 class color: нарушен RGB-контракт.");
}
return {
classId,
label: text(item.label, "E47 class label"),
disposition: disposition as E47SemanticDisposition,
colorRgb: [rgb[0]!, rgb[1]!, rgb[2]!] as const,
};
});
if (!classes.length || new Set(classes.map((item) => item.classId)).size !== classes.length) {
throw new E47SemanticSlamContractError("E47 taxonomy: классы отсутствуют или дублируются.");
}
return classes;
}
function parseResult(value: unknown): E47SemanticSlamResult {
const item = object(value, "E47 result");
exact(item.schema_version, "missioncore.e47-semantic-slam-view/v1", "E47 view schema");
exact(item.status, "diagnostic-semantic-slam-shadow", "E47 status");
exact(item.ground_truth, false, "E47 ground truth");
exact(item.semantic_authority, "diagnostic-only", "E47 semantic authority");
exact(item.navigation_or_safety_accepted, false, "E47 safety authority");
exact(item.actuation_allowed, false, "E47 actuation authority");
const provider = object(item.provider, "E47 provider");
const temporalBinding = object(item.temporal_binding, "E47 temporal binding");
const metrics = object(item.metrics, "E47 metrics");
const frames = object(metrics.frames, "E47 frame metrics");
const points = object(metrics.points, "E47 point metrics");
const observations = object(metrics.observations, "E47 observation metrics");
const runtime = object(metrics.runtime, "E47 runtime metrics");
const acceptance = object(item.acceptance, "E47 acceptance");
const frameMetrics = {
total: exact(frames.total, 4489, "E47 frame total"),
maskAvailable: integer(frames.mask_available, "E47 mask frames"),
sourceAvailable: integer(frames.source_available, "E47 source frames"),
};
if (
frameMetrics.maskAvailable > frameMetrics.total
|| frameMetrics.sourceAvailable > frameMetrics.total
) {
throw new E47SemanticSlamContractError("E47 frame accounting: нарушен контракт.");
}
const pointMetrics = {
total: integer(points.total, "E47 total points"),
projected: integer(points.projected, "E47 projected points"),
labeled: integer(points.labeled, "E47 labeled points"),
ambiguous: integer(points.ambiguous, "E47 ambiguous points"),
unprojected: integer(points.unprojected, "E47 unprojected points"),
absent: integer(points.absent, "E47 absent points"),
};
if (
pointMetrics.projected !== pointMetrics.labeled + pointMetrics.ambiguous
|| pointMetrics.total !== pointMetrics.projected
+ pointMetrics.unprojected
+ pointMetrics.absent
) {
throw new E47SemanticSlamContractError("E47 point accounting: нарушен контракт.");
}
const observationMetrics = {
total: integer(observations.total, "E47 total observations"),
labeled: integer(observations.labeled, "E47 labeled observations"),
ambiguous: integer(observations.ambiguous, "E47 ambiguous observations"),
unprojected: integer(observations.unprojected, "E47 unprojected observations"),
absent: integer(observations.absent, "E47 absent observations"),
};
if (
observationMetrics.total !== observationMetrics.labeled
+ observationMetrics.ambiguous
+ observationMetrics.unprojected
+ observationMetrics.absent
) {
throw new E47SemanticSlamContractError("E47 observation accounting: нарушен контракт.");
}
const runtimeMetrics = {
elapsedMs: finite(runtime.elapsed_ms, "E47 elapsed"),
framesPerSecond: finite(runtime.frames_per_second, "E47 FPS"),
};
if (runtimeMetrics.elapsedMs <= 0 || runtimeMetrics.framesPerSecond <= 0) {
throw new E47SemanticSlamContractError("E47 runtime accounting: нарушен контракт.");
}
return {
resultId: resultId(item.result_id),
createdAtUtc: text(item.created_at_utc, "E47 created at"),
status: "diagnostic-semantic-slam-shadow",
profileId: text(item.profile_id, "E47 profile"),
baseM4ResultId: m4ResultId(item.base_m4_result_id),
semanticResultId: text(item.semantic_result_id, "E47 semantic source"),
geometryResultId: text(item.geometry_result_id, "E47 geometry source"),
sourcePackId: text(item.source_pack_id, "E47 source pack"),
calibrationContentSha256: sha256(item.calibration_content_sha256, "E47 calibration"),
provider: {
providerId: text(provider.provider_id, "E47 provider id"),
modelId: text(provider.model_id, "E47 model id"),
modelRevision: text(provider.model_revision, "E47 model revision"),
modelWeightsSha256: sha256(provider.model_weights_sha256, "E47 model weights"),
preprocessId: text(provider.preprocess_id, "E47 preprocess id"),
},
temporalBinding: {
semanticToCamera: exact(
temporalBinding.semantic_to_camera,
"exact-sequence-and-session-time",
"E47 semantic/camera binding",
),
cameraToLidar: exact(
temporalBinding.camera_to_lidar,
"accepted-e6-nearest-host-arrival-best-effort",
"E47 camera/LiDAR binding",
),
clockBasis: exact(
temporalBinding.clock_basis,
"recorded-host-monotonic-arrival",
"E47 clock basis",
),
maximumLidarCameraDeltaMs: finite(
temporalBinding.maximum_lidar_camera_delta_ms,
"E47 maximum camera/LiDAR delta",
),
maximumPosePointDeltaMs: finite(
temporalBinding.maximum_pose_point_delta_ms,
"E47 maximum pose/point delta",
),
physicalSynchronizationProven: exact(
temporalBinding.physical_synchronization_proven,
false,
"E47 physical synchronization",
),
},
taxonomy: taxonomy(item.taxonomy),
metrics: {
frames: frameMetrics,
points: pointMetrics,
observations: observationMetrics,
runtime: runtimeMetrics,
},
acceptance: {
artifactContractPassed: exact(
acceptance.artifact_contract_passed,
true,
"E47 artifact contract",
),
frameAccountingPassed: exact(
acceptance.frame_accounting_passed,
true,
"E47 frame accounting",
),
pointAccountingPassed: exact(
acceptance.point_accounting_passed,
true,
"E47 point accounting",
),
observationBindingPassed: exact(
acceptance.observation_binding_passed,
true,
"E47 observation binding",
),
temporalBindingPassed: exact(
acceptance.temporal_binding_passed,
true,
"E47 temporal binding",
),
independentSemanticTruthPassed: exact(
acceptance.independent_semantic_truth_passed,
false,
"E47 independent truth",
),
providerPromoted: exact(
acceptance.provider_promoted,
false,
"E47 provider promotion",
),
},
limitations: array(item.limitations, "E47 limitations").map(
(entry) => text(entry, "E47 limitation"),
),
};
}
export async function fetchE47SemanticSlamResult({
resultId: requestedResultId,
fetcher = fetch,
signal,
}: {
resultId?: string;
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<E47SemanticSlamResult | null> {
if (requestedResultId !== undefined) resultId(requestedResultId);
let response = await fetcher(
requestedResultId === undefined
? "/api/v1/laboratory/e47-semantic-slam/results?limit=1"
: `/api/v1/laboratory/e47-semantic-slam/results/${requestedResultId}`,
{ headers: { Accept: "application/json" }, signal },
);
let catalogFallback = false;
if (requestedResultId !== undefined && response.status === 404) {
response = await fetcher("/api/v1/laboratory/e47-semantic-slam/results?limit=10", {
headers: { Accept: "application/json" },
signal,
});
catalogFallback = true;
}
if (!response.ok) {
throw new E47SemanticSlamContractError(`E47 LAB недоступен: HTTP ${response.status}.`);
}
const payload = await response.json();
if (requestedResultId === undefined || catalogFallback) {
const catalog = object(payload, "E47 catalog");
exact(
catalog.schema_version,
"missioncore.e47-semantic-slam-catalog/v1",
"E47 catalog schema",
);
const items = array(catalog.items, "E47 catalog items");
if (!items.length) return null;
const selected = requestedResultId === undefined
? items[0]
: items.find((candidate) => (
typeof candidate === "object"
&& candidate !== null
&& (candidate as Record<string, unknown>).result_id === requestedResultId
));
if (selected === undefined) {
throw new E47SemanticSlamContractError(
"E47 requested result: точный semantic-result отсутствует в каталоге.",
);
}
const parsed = parseResult(selected);
if (requestedResultId !== undefined && parsed.resultId !== requestedResultId) {
throw new E47SemanticSlamContractError("E47 requested result: нарушена идентичность.");
}
return parsed;
}
const parsed = parseResult(payload);
if (parsed.resultId !== requestedResultId) {
throw new E47SemanticSlamContractError("E47 requested result: нарушена идентичность.");
}
return parsed;
}
function parseFrame(
value: unknown,
expectedSequence: number,
declaredTaxonomy: readonly E47SemanticClass[] | undefined,
): E47SemanticTimelineFrame {
const item = object(value, "E47 semantic frame");
exact(item.schema_version, "missioncore.e47-semantic-slam-frame/v1", "E47 frame schema");
const sequence = integer(item.sequence, "E47 frame sequence");
if (sequence !== expectedSequence) {
throw new E47SemanticSlamContractError("E47 frame sequence: нарушен порядок.");
}
const sourcePointCount = integer(item.source_point_count, "E47 frame source points");
const classIds = array(item.class_ids, "E47 frame classes").map((entry) => {
const parsed = finite(entry, "E47 frame class");
if (!Number.isInteger(parsed) || parsed < -1 || parsed > 255) {
throw new E47SemanticSlamContractError("E47 frame class: вышел за контракт.");
}
return parsed;
});
const statusCodes = array(item.status_codes, "E47 frame statuses").map((entry) => {
const parsed = integer(entry, "E47 frame status");
if (parsed > 3) {
throw new E47SemanticSlamContractError("E47 frame status: неизвестное значение.");
}
return parsed;
});
if (classIds.length !== sourcePointCount || statusCodes.length !== sourcePointCount) {
throw new E47SemanticSlamContractError("E47 frame point accounting: нарушен контракт.");
}
if (classIds.some((classId, index) => {
const status = statusCodes[index];
return status === 0 || status === 1 ? classId !== -1 : classId < 0;
})) {
throw new E47SemanticSlamContractError("E47 frame class/status binding: нарушен контракт.");
}
if (declaredTaxonomy) {
const classesById = new Map(declaredTaxonomy.map((item) => [item.classId, item]));
if (classIds.some((classId, index) => {
const status = statusCodes[index];
if (status !== 2 && status !== 3) return false;
const semanticClass = classesById.get(classId);
return !semanticClass
|| (status === 2 && semanticClass.disposition !== "ambiguous")
|| (status === 3 && semanticClass.disposition !== "labeled");
})) {
throw new E47SemanticSlamContractError("E47 frame taxonomy binding: нарушен контракт.");
}
}
const counts = object(item.counts, "E47 frame counts");
const parsedCounts = {
labeled: integer(counts.labeled, "E47 frame labeled"),
ambiguous: integer(counts.ambiguous, "E47 frame ambiguous"),
unprojected: integer(counts.unprojected, "E47 frame unprojected"),
absent: integer(counts.absent, "E47 frame absent"),
};
if (Object.values(parsedCounts).reduce((sum, count) => sum + count, 0) !== sourcePointCount) {
throw new E47SemanticSlamContractError("E47 frame status accounting: нарушен контракт.");
}
const actualCounts = {
labeled: statusCodes.filter((status) => status === 3).length,
ambiguous: statusCodes.filter((status) => status === 2).length,
unprojected: statusCodes.filter((status) => status === 1).length,
absent: statusCodes.filter((status) => status === 0).length,
};
if (Object.keys(actualCounts).some(
(key) => actualCounts[key as keyof typeof actualCounts]
!== parsedCounts[key as keyof typeof parsedCounts],
)) {
throw new E47SemanticSlamContractError("E47 frame status histogram: нарушен контракт.");
}
return { sequence, sourcePointCount, classIds, statusCodes, counts: parsedCounts };
}
export async function fetchE47SemanticTimelineChunk(
result: string,
startSequence: number,
frameCount: number,
{
fetcher = fetch,
signal,
taxonomy,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
taxonomy?: readonly E47SemanticClass[];
} = {},
): Promise<E47SemanticTimelineChunk> {
resultId(result);
const parameters = new URLSearchParams({
start: String(startSequence),
count: String(frameCount),
});
const response = await fetcher(
`/api/v1/laboratory/e47-semantic-slam/results/${result}/timeline/chunk?${parameters}`,
{ headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new E47SemanticSlamContractError(`E47 timeline chunk: HTTP ${response.status}.`);
}
const payload = object(await response.json(), "E47 semantic chunk");
exact(payload.schema_version, "missioncore.e47-semantic-slam-chunk/v1", "E47 chunk schema");
exact(payload.result_id, result, "E47 chunk result");
const parsedStart = integer(payload.start_sequence, "E47 chunk start");
if (parsedStart !== startSequence) {
throw new E47SemanticSlamContractError("E47 chunk start: нарушен контракт.");
}
const frames = array(payload.frames, "E47 chunk frames").map(
(frame, offset) => parseFrame(frame, parsedStart + offset, taxonomy),
);
const parsedCount = integer(payload.frame_count, "E47 chunk count");
if (parsedCount !== frames.length || parsedCount > frameCount) {
throw new E47SemanticSlamContractError("E47 chunk frame count: нарушен контракт.");
}
return {
resultId: result,
startSequence: parsedStart,
frameCount: parsedCount,
nextSequence: payload.next_sequence === null
? null
: integer(payload.next_sequence, "E47 next sequence"),
frames,
};
}
export function e47SemanticMaskUrl(result: string, sequence: number): string {
resultId(result);
if (!Number.isInteger(sequence) || sequence < 0 || sequence >= 4489) {
throw new E47SemanticSlamContractError("E47 mask sequence: вне recorded replay.");
}
return `/api/v1/laboratory/e47-semantic-slam/results/${result}/masks/${sequence}`;
}
@@ -0,0 +1,197 @@
export type LaboratoryEvidenceCompleteness = "recorded" | "not-recorded";
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export interface LaboratoryEvidenceArtifact {
kind: string | null;
path: string;
byteLength: number;
sha256: string;
schemaVersion: string | null;
mediaType: string | null;
verified: true;
}
export interface LaboratoryEvidenceReport {
schemaVersion: "missioncore.laboratory-evidence-report/v1";
workId: string;
resultId: string;
createdAtUtc: string | null;
access: "read-only";
proof: {
documentSchemaVersion: string;
documentSha256: string;
identitySha256: string;
reportSchemaVersion: string | null;
reportSha256: string | null;
artifactCount: number;
verifiedArtifactCount: number;
};
completeness: Readonly<Record<string, LaboratoryEvidenceCompleteness>>;
identity: Record<string, JsonValue>;
source: Record<string, JsonValue> | null;
configuration: Record<string, JsonValue> | null;
method: Record<string, JsonValue> | null;
execution: Record<string, JsonValue> | null;
resources: Record<string, JsonValue> | null;
metrics: Record<string, JsonValue> | null;
gates: Record<string, JsonValue> | null;
decision: JsonValue | undefined;
limitations: JsonValue | undefined;
authority: Record<string, JsonValue> | null;
artifacts: readonly LaboratoryEvidenceArtifact[];
visualEvidence: Record<string, JsonValue>;
rawReport: Record<string, JsonValue>;
canonicalJson: Record<string, JsonValue>;
}
export class LaboratoryEvidenceReportContractError extends Error {}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new LaboratoryEvidenceReportContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function nullableRecord(value: unknown, label: string): Record<string, JsonValue> | null {
return value === null ? null : jsonRecord(value, label);
}
function jsonRecord(value: unknown, label: string): Record<string, JsonValue> {
const document = record(value, label);
for (const [key, item] of Object.entries(document)) validateJson(item, `${label}.${key}`);
return document as Record<string, JsonValue>;
}
function validateJson(value: unknown, label: string): asserts value is JsonValue {
if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
if (typeof value === "number" && !Number.isFinite(value)) {
throw new LaboratoryEvidenceReportContractError(`${label}: число не конечно.`);
}
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => validateJson(item, `${label}[${index}]`));
return;
}
const document = record(value, label);
Object.entries(document).forEach(([key, item]) => validateJson(item, `${label}.${key}`));
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new LaboratoryEvidenceReportContractError(`${label}: ожидалась строка.`);
}
return value;
}
function nullableText(value: unknown, label: string): string | null {
return value === null ? null : text(value, label);
}
function integer(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new LaboratoryEvidenceReportContractError(`${label}: ожидалось целое число.`);
}
return value;
}
function parseArtifact(value: unknown): LaboratoryEvidenceArtifact {
const artifact = record(value, "LAB artifact");
if (artifact.verified !== true) {
throw new LaboratoryEvidenceReportContractError("LAB artifact: хэш не подтверждён.");
}
return {
kind: nullableText(artifact.kind, "LAB artifact kind"),
path: text(artifact.path, "LAB artifact path"),
byteLength: integer(artifact.byte_length, "LAB artifact byte_length"),
sha256: text(artifact.sha256, "LAB artifact sha256"),
schemaVersion: nullableText(artifact.schema_version, "LAB artifact schema_version"),
mediaType: nullableText(artifact.media_type, "LAB artifact media_type"),
verified: true,
};
}
export function parseLaboratoryEvidenceReport(value: unknown): LaboratoryEvidenceReport {
const payload = jsonRecord(value, "LAB evidence report") as Record<string, unknown>;
if (payload.schema_version !== "missioncore.laboratory-evidence-report/v1") {
throw new LaboratoryEvidenceReportContractError("LAB evidence report: неизвестная схема.");
}
if (payload.access !== "read-only") {
throw new LaboratoryEvidenceReportContractError("LAB evidence report: доступ не read-only.");
}
const proof = record(payload.proof, "LAB evidence proof");
const completenessPayload = record(payload.completeness, "LAB evidence completeness");
const completeness: Record<string, LaboratoryEvidenceCompleteness> = {};
for (const [key, state] of Object.entries(completenessPayload)) {
if (state !== "recorded" && state !== "not-recorded") {
throw new LaboratoryEvidenceReportContractError(`LAB completeness ${key}: неизвестное значение.`);
}
completeness[key] = state;
}
if (!Array.isArray(payload.artifacts)) {
throw new LaboratoryEvidenceReportContractError("LAB evidence artifacts: ожидался список.");
}
return {
schemaVersion: "missioncore.laboratory-evidence-report/v1",
workId: text(payload.work_id, "LAB work_id"),
resultId: text(payload.result_id, "LAB result_id"),
createdAtUtc: nullableText(payload.created_at_utc, "LAB created_at_utc"),
access: "read-only",
proof: {
documentSchemaVersion: text(proof.document_schema_version, "document schema"),
documentSha256: text(proof.document_sha256, "document sha256"),
identitySha256: text(proof.identity_sha256, "identity sha256"),
reportSchemaVersion: nullableText(proof.report_schema_version, "report schema"),
reportSha256: nullableText(proof.report_sha256, "report sha256"),
artifactCount: integer(proof.artifact_count, "artifact_count"),
verifiedArtifactCount: integer(proof.verified_artifact_count, "verified_artifact_count"),
},
completeness,
identity: jsonRecord(payload.identity, "LAB identity"),
source: nullableRecord(payload.source, "LAB source"),
configuration: nullableRecord(payload.configuration, "LAB configuration"),
method: nullableRecord(payload.method, "LAB method"),
execution: nullableRecord(payload.execution, "LAB execution"),
resources: nullableRecord(payload.resources, "LAB resources"),
metrics: nullableRecord(payload.metrics, "LAB metrics"),
gates: nullableRecord(payload.gates, "LAB gates"),
decision: payload.decision as JsonValue | undefined,
limitations: payload.limitations as JsonValue | undefined,
authority: nullableRecord(payload.authority, "LAB authority"),
artifacts: payload.artifacts.map(parseArtifact),
visualEvidence: jsonRecord(payload.visual_evidence, "LAB visual evidence"),
rawReport: jsonRecord(payload.raw_report, "LAB raw report"),
canonicalJson: payload as Record<string, JsonValue>,
};
}
export async function fetchLaboratoryEvidenceReport({
workId,
resultId,
fetcher = fetch,
signal,
}: {
workId: string;
resultId: string;
fetcher?: typeof fetch;
signal?: AbortSignal;
}): Promise<LaboratoryEvidenceReport> {
const response = await fetcher(
`/api/v1/laboratory/evidence-reports/${encodeURIComponent(workId)}/${encodeURIComponent(resultId)}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new LaboratoryEvidenceReportContractError(
response.status === 404
? "Для этой LAB канонический evidence-report пока не опубликован."
: `Evidence-report LAB не прошёл серверную проверку: HTTP ${response.status}.`,
);
}
const report = parseLaboratoryEvidenceReport(await response.json());
if (report.workId !== workId || report.resultId !== resultId) {
throw new LaboratoryEvidenceReportContractError("Evidence-report не совпадает с выбранной LAB identity.");
}
return report;
}
@@ -0,0 +1,475 @@
import {
AdvancedLaboratoryContractError,
type LaboratoryFetch,
} from "./advancedResults";
import type { L3VisualBox } from "./l3PointPillarsVisualAudit";
export interface L31ClassCounts {
Vehicle: number;
Pedestrian: number;
Cyclist: number;
}
export interface L31VisualFrameSummary {
frameId: string;
frameIndex: number;
sessionSeconds: number;
sourcePointCount: number;
predictionCount: number;
classCounts: L31ClassCounts;
inferenceMs: number;
}
export interface L31PointPillarsRavnovesResult {
resultId: string;
createdAtUtc: string;
status: "cross-domain-transfer-measured-visual-review-required";
sourceSessionId: string;
sourcePackId: string;
sourceLogicalContentSha256: string;
model: {
name: "pointpillars";
sourceModelSha256: string;
engineSha256: string;
embeddedScoreThreshold: number;
};
execution: {
workerHostId: "worker-006";
sequential: true;
parallelWorkers: 1;
sourcePaced: false;
existingTritonOnly: true;
};
metrics: {
frameCount: number;
inputAdmissionFraction: number;
outputSchemaValidFraction: number;
framesWithPredictions: number;
framesWithVehiclePredictions: number;
predictionCount: number;
classCounts: L31ClassCounts;
inferenceLatencyMs: { p50: number; p95: number; maximum: number };
poseBindingAgeMs: { p50: number; p95: number; maximum: number };
deterministicReplayFraction: number;
deterministicReplayFrames: number;
};
frames: readonly L31VisualFrameSummary[];
limitations: readonly string[];
}
export interface L31VisualFrame {
frameId: string;
summary: L31VisualFrameSummary & {
deterministicReplay: boolean;
replayInferenceMs: number;
visualBoxCount: number;
visualBoxTruncated: boolean;
};
sourcePointCount: number;
modelRangePointCount: number;
sampledPointCount: number;
pointsXyzi: readonly number[];
truthBoxes: readonly L3VisualBox[];
predictionBoxes: readonly L3VisualBox[];
}
const RESULT_ID = /^l31-pointpillars-ravnoves-[a-f0-9]{64}$/;
const PACK_ID = /^lidar-replay-pack-[a-f0-9]{64}$/;
const SHA256 = /^[a-f0-9]{64}$/;
const FRAME_ID = /^[0-9]{6}$/;
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`);
}
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`);
}
return value;
}
function exact<T extends string>(value: unknown, expected: T, label: string): T {
if (value !== expected) {
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
}
return expected;
}
function number(value: unknown, label: string, minimum = 0): number {
if (
typeof value !== "number"
|| !Number.isFinite(value)
|| value < minimum
) {
throw new AdvancedLaboratoryContractError(`${label}: неверное число.`);
}
return value;
}
function integer(value: unknown, label: string): number {
const parsed = number(value, label);
if (!Number.isInteger(parsed)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое.`);
}
return parsed;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new AdvancedLaboratoryContractError(`${label}: ожидался boolean.`);
}
return value;
}
function exactBoolean(
value: unknown,
expected: boolean,
label: string,
): boolean {
const parsed = boolean(value, label);
if (parsed !== expected) {
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
}
return parsed;
}
function classCounts(value: unknown, label: string): L31ClassCounts {
const counts = record(value, label);
if (
Object.keys(counts).sort().join(",") !== "Cyclist,Pedestrian,Vehicle"
) {
throw new AdvancedLaboratoryContractError(`${label}: классы изменились.`);
}
return {
Vehicle: integer(counts.Vehicle, `${label}.Vehicle`),
Pedestrian: integer(counts.Pedestrian, `${label}.Pedestrian`),
Cyclist: integer(counts.Cyclist, `${label}.Cyclist`),
};
}
function distribution(
value: unknown,
label: string,
): { p50: number; p95: number; maximum: number } {
const item = record(value, label);
return {
p50: number(item.p50, `${label}.p50`),
p95: number(item.p95, `${label}.p95`),
maximum: number(item.maximum, `${label}.maximum`),
};
}
function parseSummary(value: unknown): L31VisualFrameSummary {
const item = record(value, "L3.1 frame");
const frameId = string(item.frame_id, "L3.1 frame_id");
if (!FRAME_ID.test(frameId)) {
throw new AdvancedLaboratoryContractError("L3.1 frame_id: неверный формат.");
}
return {
frameId,
frameIndex: integer(item.frame_index, "L3.1 frame_index"),
sessionSeconds: number(item.session_seconds, "L3.1 session_seconds"),
sourcePointCount: integer(
item.source_point_count,
"L3.1 source_point_count",
),
predictionCount: integer(
item.prediction_count,
"L3.1 prediction_count",
),
classCounts: classCounts(item.class_counts, "L3.1 class_counts"),
inferenceMs: number(
item.inference_ms,
"L3.1 inference_ms",
Number.MIN_VALUE,
),
};
}
function parseResult(value: unknown): L31PointPillarsRavnovesResult {
const item = record(value, "L3.1 result");
exact(
item.schema_version,
"missioncore.l31-pointpillars-ravnoves-result/v1",
"L3.1 schema_version",
);
exact(item.access, "read-only", "L3.1 access");
const resultId = string(item.result_id, "L3.1 result_id");
const sourcePackId = string(item.source_pack_id, "L3.1 source_pack_id");
const logicalSha = string(
item.source_logical_content_sha256,
"L3.1 logical content",
);
if (
!RESULT_ID.test(resultId)
|| !PACK_ID.test(sourcePackId)
|| !SHA256.test(logicalSha)
) {
throw new AdvancedLaboratoryContractError(
"L3.1: нарушена идентичность результата.",
);
}
const model = record(item.model, "L3.1 model");
const execution = record(item.execution, "L3.1 execution");
const metrics = record(item.metrics, "L3.1 metrics");
const frames = array(item.frames, "L3.1 frames").map(parseSummary);
if (
!frames.length
|| frames.length > 18
|| new Set(frames.map(({ frameId }) => frameId)).size !== frames.length
) {
throw new AdvancedLaboratoryContractError("L3.1 frames: неверный каталог.");
}
return {
resultId,
createdAtUtc: string(item.created_at_utc, "L3.1 created_at_utc"),
status: exact(
item.status,
"cross-domain-transfer-measured-visual-review-required",
"L3.1 status",
),
sourceSessionId: exact(
item.source_session_id,
"20260720T065719Z_viewer_live",
"L3.1 source_session_id",
),
sourcePackId,
sourceLogicalContentSha256: logicalSha,
model: {
name: exact(model.name, "pointpillars", "L3.1 model.name"),
sourceModelSha256: string(
model.source_model_sha256,
"L3.1 model sha",
),
engineSha256: string(model.engine_sha256, "L3.1 engine sha"),
embeddedScoreThreshold: number(
model.embedded_score_threshold,
"L3.1 score threshold",
),
},
execution: {
workerHostId: exact(
execution.worker_host_id,
"worker-006",
"L3.1 worker",
),
sequential: exactBoolean(
execution.sequential,
true,
"L3.1 sequential",
) as true,
parallelWorkers: (
integer(execution.parallel_workers, "L3.1 parallel workers") === 1
? 1
: (() => {
throw new AdvancedLaboratoryContractError(
"L3.1 parallel workers: нарушен контракт.",
);
})()
),
sourcePaced: exactBoolean(
execution.source_paced,
false,
"L3.1 source paced",
) as false,
existingTritonOnly: exactBoolean(
execution.existing_triton_only,
true,
"L3.1 Triton",
) as true,
},
metrics: {
frameCount: integer(metrics.frame_count, "L3.1 frame count"),
inputAdmissionFraction: number(
metrics.input_admission_fraction,
"L3.1 admission",
),
outputSchemaValidFraction: number(
metrics.output_schema_valid_fraction,
"L3.1 schema valid",
),
framesWithPredictions: integer(
metrics.frames_with_predictions,
"L3.1 frames with predictions",
),
framesWithVehiclePredictions: integer(
metrics.frames_with_vehicle_predictions,
"L3.1 frames with vehicles",
),
predictionCount: integer(
metrics.prediction_count,
"L3.1 predictions",
),
classCounts: classCounts(metrics.class_counts, "L3.1 metric classes"),
inferenceLatencyMs: distribution(
metrics.inference_latency_ms,
"L3.1 latency",
),
poseBindingAgeMs: distribution(
metrics.pose_binding_age_ms,
"L3.1 pose age",
),
deterministicReplayFraction: number(
metrics.deterministic_replay_fraction,
"L3.1 determinism",
),
deterministicReplayFrames: integer(
metrics.deterministic_replay_frames,
"L3.1 determinism frames",
),
},
frames,
limitations: array(item.limitations, "L3.1 limitations").map(
(entry) => string(entry, "L3.1 limitation"),
),
};
}
function parseBox(value: unknown): L3VisualBox {
const box = record(value, "L3.1 box");
const center = [
number(box.x_m, "L3.1 box.x", -Infinity),
number(box.y_m, "L3.1 box.y", -Infinity),
number(box.z_m, "L3.1 box.z", -Infinity),
] as const;
const size = [
number(box.length_m, "L3.1 box.length", Number.MIN_VALUE),
number(box.width_m, "L3.1 box.width", Number.MIN_VALUE),
number(box.height_m, "L3.1 box.height", Number.MIN_VALUE),
] as const;
const modelClass = string(box.model_class, "L3.1 box.class");
if (!["Vehicle", "Pedestrian", "Cyclist"].includes(modelClass)) {
throw new AdvancedLaboratoryContractError("L3.1 box.class: неизвестен.");
}
return {
benchmarkClass: modelClass,
centerXyzM: center,
sizeLwhM: size,
yawRad: number(box.yaw_rad, "L3.1 box.yaw", -Infinity),
status: "model-prediction",
score: number(box.score, "L3.1 box.score"),
};
}
export async function fetchL31PointPillarsRavnoves({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<L31PointPillarsRavnovesResult | null> {
const response = await fetcher(
"/api/v1/laboratory/l31/pointpillars-ravnoves/results?limit=1",
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new AdvancedLaboratoryContractError(
`L3.1 RAVNOVES недоступен: HTTP ${response.status}.`,
);
}
const catalog = record(await response.json(), "L3.1 catalog");
exact(
catalog.schema_version,
"missioncore.l31-pointpillars-ravnoves-catalog-results/v1",
"L3.1 catalog schema",
);
const items = array(catalog.items, "L3.1 catalog.items");
if (items.length > 1) {
throw new AdvancedLaboratoryContractError("L3.1 catalog: лишние результаты.");
}
return items.length ? parseResult(items[0]) : null;
}
export async function fetchL31PointPillarsRavnovesFrame(
resultId: string,
frameId: string,
{
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {},
): Promise<L31VisualFrame> {
if (!RESULT_ID.test(resultId) || !FRAME_ID.test(frameId)) {
throw new AdvancedLaboratoryContractError("L3.1 frame: неверная identity.");
}
const response = await fetcher(
`/api/v1/laboratory/l31/pointpillars-ravnoves/${resultId}/frames/${frameId}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new AdvancedLaboratoryContractError(
`L3.1 frame недоступен: HTTP ${response.status}.`,
);
}
const payload = record(await response.json(), "L3.1 visual frame");
exact(
payload.schema_version,
"missioncore.l31-pointpillars-ravnoves-visual-frame/v1",
"L3.1 visual schema",
);
exact(payload.frame_id, frameId, "L3.1 visual frame_id");
const summaryRaw = record(payload.summary, "L3.1 visual summary");
const summary = parseSummary(summaryRaw);
const points = record(payload.points, "L3.1 points");
exact(points.layout, "flat-xyzi", "L3.1 points.layout");
const pointValues = array(points.values, "L3.1 points.values").map(
(entry, index) => number(entry, `L3.1 points[${index}]`, -Infinity),
);
const sampledPointCount = integer(
points.sampled_point_count,
"L3.1 sampled points",
);
if (sampledPointCount > 12_000 || pointValues.length !== sampledPointCount * 4) {
throw new AdvancedLaboratoryContractError("L3.1 points: нарушен bound.");
}
return {
frameId,
summary: {
...summary,
deterministicReplay: boolean(
summaryRaw.deterministic_replay,
"L3.1 deterministic replay",
),
replayInferenceMs: number(
summaryRaw.replay_inference_ms,
"L3.1 replay latency",
),
visualBoxCount: integer(
summaryRaw.visual_box_count,
"L3.1 visual box count",
),
visualBoxTruncated: boolean(
summaryRaw.visual_box_truncated,
"L3.1 visual box truncated",
),
},
sourcePointCount: integer(
points.source_point_count,
"L3.1 source points",
),
modelRangePointCount: integer(
points.model_range_point_count,
"L3.1 model range points",
),
sampledPointCount,
pointsXyzi: pointValues,
truthBoxes: [],
predictionBoxes: array(
payload.prediction_boxes,
"L3.1 prediction boxes",
).map(parseBox),
};
}
@@ -0,0 +1,358 @@
import {
AdvancedLaboratoryContractError,
type LaboratoryFetch,
} from "./advancedResults";
import type { L3VisualBox } from "./l3PointPillarsVisualAudit";
import type { L31ClassCounts } from "./l31PointPillarsRavnoves";
export interface L32FrameSummary {
frameId: string;
frameIndex: number;
sessionSeconds: number;
sourcePointCount: number;
predictionCount: number;
classCounts: L31ClassCounts;
inferenceMs: number;
cameraSourceFrameIndex: number;
cameraSessionSeconds: number;
cameraDeltaMs: number;
}
export interface L32PointPillarsCameraReviewResult {
resultId: string;
createdAtUtc: string;
status: "camera-bound-review-rejects-current-candidate";
sourceSessionId: string;
sourceL31ResultId: string;
sourceE10PackId: string;
metrics: {
frameCount: number;
predictionCount: number;
classCounts: L31ClassCounts;
inferenceLatencyMs: { p50: number; p95: number; maximum: number };
reviewFrameCount: number;
reviewPredictionCount: number;
reviewVisibleProjectedBoxCount: number;
reviewProjectedPointCount: number;
scoreBelow025Fraction: number;
scoreBelow050Fraction: number;
cameraBindingAbsoluteDeltaMs: { p95: number; maximum: number };
};
frames: readonly L32FrameSummary[];
limitations: readonly string[];
}
export interface L32ProjectedBox {
modelClass: string;
score: number;
segmentsXyxy: readonly number[];
}
export interface L32VisualFrame {
frameId: string;
summary: L32FrameSummary;
cameraUrl: string;
cameraWidth: number;
cameraHeight: number;
modelRangePointCount: number;
pointsXyzi: readonly number[];
predictionBoxes: readonly L3VisualBox[];
projectedPointsXyd: readonly number[];
projectedBoxes: readonly L32ProjectedBox[];
}
export const L32_REVIEW_SCORE_THRESHOLD = 0.25;
const RESULT_ID = /^l32-pointpillars-camera-review-[a-f0-9]{64}$/;
const L31_RESULT_ID = /^l31-pointpillars-ravnoves-[a-f0-9]{64}$/;
const PACK_ID = /^e10-lidar-pack-[a-f0-9]{64}$/;
const FRAME_ID = /^[0-9]{6}$/;
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`);
}
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`);
}
return value;
}
function exact<T extends string>(value: unknown, expected: T, label: string): T {
if (value !== expected) {
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
}
return expected;
}
function number(value: unknown, label: string, minimum = 0): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) {
throw new AdvancedLaboratoryContractError(`${label}: неверное число.`);
}
return value;
}
function integer(value: unknown, label: string): number {
const parsed = number(value, label);
if (!Number.isInteger(parsed)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое.`);
}
return parsed;
}
function counts(value: unknown, label: string): L31ClassCounts {
const item = record(value, label);
if (Object.keys(item).sort().join(",") !== "Cyclist,Pedestrian,Vehicle") {
throw new AdvancedLaboratoryContractError(`${label}: классы изменились.`);
}
return {
Vehicle: integer(item.Vehicle, `${label}.Vehicle`),
Pedestrian: integer(item.Pedestrian, `${label}.Pedestrian`),
Cyclist: integer(item.Cyclist, `${label}.Cyclist`),
};
}
function distribution(value: unknown, label: string) {
const item = record(value, label);
return {
p50: number(item.p50, `${label}.p50`),
p95: number(item.p95, `${label}.p95`),
maximum: number(item.maximum, `${label}.maximum`),
};
}
function parseSummary(value: unknown): L32FrameSummary {
const item = record(value, "L3.2 frame");
const frameId = string(item.frame_id, "L3.2 frame_id");
if (!FRAME_ID.test(frameId)) {
throw new AdvancedLaboratoryContractError("L3.2 frame_id: неверный формат.");
}
return {
frameId,
frameIndex: integer(item.frame_index, "L3.2 frame index"),
sessionSeconds: number(item.session_seconds, "L3.2 session seconds"),
sourcePointCount: integer(item.source_point_count, "L3.2 source points"),
predictionCount: integer(item.prediction_count, "L3.2 predictions"),
classCounts: counts(item.class_counts, "L3.2 classes"),
inferenceMs: number(item.inference_ms, "L3.2 inference", Number.MIN_VALUE),
cameraSourceFrameIndex: integer(
item.camera_source_frame_index,
"L3.2 camera frame",
),
cameraSessionSeconds: number(
item.camera_session_seconds,
"L3.2 camera seconds",
),
cameraDeltaMs: number(item.camera_delta_ms, "L3.2 camera delta", -100),
};
}
function parseBox(value: unknown): L3VisualBox {
const box = record(value, "L3.2 box");
const modelClass = string(box.model_class, "L3.2 box class");
return {
benchmarkClass: modelClass,
centerXyzM: [
number(box.x_m, "L3.2 box x", -Infinity),
number(box.y_m, "L3.2 box y", -Infinity),
number(box.z_m, "L3.2 box z", -Infinity),
],
sizeLwhM: [
number(box.length_m, "L3.2 box length", Number.MIN_VALUE),
number(box.width_m, "L3.2 box width", Number.MIN_VALUE),
number(box.height_m, "L3.2 box height", Number.MIN_VALUE),
],
yawRad: number(box.yaw_rad, "L3.2 box yaw", -Infinity),
status: "model-prediction",
score: number(box.score, "L3.2 box score"),
};
}
function parseResult(value: unknown): L32PointPillarsCameraReviewResult {
const item = record(value, "L3.2 result");
exact(
item.schema_version,
"missioncore.l32-pointpillars-camera-review-result/v1",
"L3.2 schema",
);
exact(item.access, "read-only", "L3.2 access");
const resultId = string(item.result_id, "L3.2 result id");
const sourceL31ResultId = string(item.source_l31_result_id, "L3.2 L3.1 id");
const sourceE10PackId = string(item.source_e10_pack_id, "L3.2 E10 id");
if (
!RESULT_ID.test(resultId)
|| !L31_RESULT_ID.test(sourceL31ResultId)
|| !PACK_ID.test(sourceE10PackId)
) {
throw new AdvancedLaboratoryContractError("L3.2: неверная identity.");
}
const metrics = record(item.metrics, "L3.2 metrics");
const cameraDelta = distribution(
metrics.camera_binding_absolute_delta_ms,
"L3.2 camera delta",
);
const frames = array(item.frames, "L3.2 frames").map(parseSummary);
if (!frames.length || frames.length > 18) {
throw new AdvancedLaboratoryContractError("L3.2 frames: нарушен bound.");
}
return {
resultId,
createdAtUtc: string(item.created_at_utc, "L3.2 created at"),
status: exact(
item.status,
"camera-bound-review-rejects-current-candidate",
"L3.2 status",
),
sourceSessionId: exact(
item.source_session_id,
"20260720T065719Z_viewer_live",
"L3.2 session",
),
sourceL31ResultId,
sourceE10PackId,
metrics: {
frameCount: integer(metrics.frame_count, "L3.2 frame count"),
predictionCount: integer(metrics.prediction_count, "L3.2 predictions"),
classCounts: counts(metrics.class_counts, "L3.2 metric classes"),
inferenceLatencyMs: distribution(
metrics.inference_latency_ms,
"L3.2 inference latency",
),
reviewFrameCount: integer(metrics.review_frame_count, "L3.2 review frames"),
reviewPredictionCount: integer(
metrics.review_prediction_count,
"L3.2 review predictions",
),
reviewVisibleProjectedBoxCount: integer(
metrics.review_visible_projected_box_count,
"L3.2 visible boxes",
),
reviewProjectedPointCount: integer(
metrics.review_projected_point_count,
"L3.2 projected points",
),
scoreBelow025Fraction: number(
metrics.score_below_0_25_fraction,
"L3.2 low score 025",
),
scoreBelow050Fraction: number(
metrics.score_below_0_50_fraction,
"L3.2 low score 050",
),
cameraBindingAbsoluteDeltaMs: cameraDelta,
},
frames,
limitations: array(item.limitations, "L3.2 limitations").map(
(entry) => string(entry, "L3.2 limitation"),
),
};
}
export async function fetchL32PointPillarsCameraReview({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<L32PointPillarsCameraReviewResult | null> {
const response = await fetcher(
"/api/v1/laboratory/l32/pointpillars-camera-review/results?limit=1",
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new AdvancedLaboratoryContractError(
`L3.2 RAVNOVES недоступен: HTTP ${response.status}.`,
);
}
const catalog = record(await response.json(), "L3.2 catalog");
exact(
catalog.schema_version,
"missioncore.l32-pointpillars-camera-review-catalog-results/v1",
"L3.2 catalog schema",
);
const items = array(catalog.items, "L3.2 catalog items");
return items.length ? parseResult(items[0]) : null;
}
export async function fetchL32PointPillarsCameraReviewFrame(
resultId: string,
frameId: string,
{
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {},
): Promise<L32VisualFrame> {
if (!RESULT_ID.test(resultId) || !FRAME_ID.test(frameId)) {
throw new AdvancedLaboratoryContractError("L3.2 frame: неверная identity.");
}
const response = await fetcher(
`/api/v1/laboratory/l32/pointpillars-camera-review/${resultId}/frames/${frameId}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new AdvancedLaboratoryContractError(
`L3.2 frame недоступен: HTTP ${response.status}.`,
);
}
const item = record(await response.json(), "L3.2 visual frame");
exact(
item.schema_version,
"missioncore.l32-pointpillars-camera-review-frame/v1",
"L3.2 frame schema",
);
const summary = parseSummary(item.summary);
const camera = record(item.camera, "L3.2 camera");
const points = record(item.points, "L3.2 points");
exact(points.layout, "flat-xyzi", "L3.2 point layout");
const values = array(points.values, "L3.2 point values").map(
(entry, index) => number(entry, `L3.2 points[${index}]`, -Infinity),
);
const projection = record(item.camera_projection, "L3.2 projection");
exact(projection.point_layout, "flat-xy-depth-m", "L3.2 projection layout");
const projected = array(projection.point_values, "L3.2 projected points").map(
(entry, index) => number(entry, `L3.2 projected[${index}]`, -Infinity),
);
if (projected.length !== integer(projection.point_count, "L3.2 point count") * 3) {
throw new AdvancedLaboratoryContractError("L3.2 projection: нарушен размер.");
}
return {
frameId,
summary,
cameraUrl: `/api/v1/laboratory/l32/pointpillars-camera-review/${resultId}/frames/${frameId}/camera`,
cameraWidth: integer(camera.width, "L3.2 camera width"),
cameraHeight: integer(camera.height, "L3.2 camera height"),
modelRangePointCount: integer(
points.model_range_point_count,
"L3.2 model range points",
),
pointsXyzi: values,
predictionBoxes: array(item.prediction_boxes, "L3.2 boxes").map(parseBox),
projectedPointsXyd: projected,
projectedBoxes: array(projection.boxes, "L3.2 projected boxes").map(
(entry) => {
const box = record(entry, "L3.2 projected box");
return {
modelClass: string(box.model_class, "L3.2 projected class"),
score: number(box.score, "L3.2 projected score"),
segmentsXyxy: array(box.segments_xyxy, "L3.2 projected segments").map(
(value, index) => number(value, `L3.2 segment[${index}]`, -Infinity),
),
};
},
),
};
}
@@ -0,0 +1,396 @@
import {
AdvancedLaboratoryContractError,
type LaboratoryFetch,
} from "./advancedResults";
import type { L3VisualBox } from "./l3PointPillarsVisualAudit";
export type L33SemanticProvenance =
| "recorded-current"
| "bounded-track-interpolation"
| "rectified-detector-current"
| "e23-temporal-hold";
export interface L33FrameSummary {
frameId: string;
frameIndex: number;
sessionSeconds: number;
cameraSourceFrameIndex: number;
cameraSessionSeconds: number;
cameraDeltaMs: number;
sourcePointCount: number;
detectionCount: number;
rangedDetectionCount: number;
cameraOnlyDetectionCount: number;
cuboidCount: number;
semanticProvenance: L33SemanticProvenance;
}
export interface L33CameraFirstDetectorReviewResult {
resultId: string;
createdAtUtc: string;
status: "camera-first-boundary-restored-shadow-only";
sourceSessionId: string;
sourceL32ResultId: string;
sourceE26ResultId: string;
sourceE29ResultId: string;
sourceE10PackId: string;
sourceWorldStateResultId: string;
detector: {
id: string;
architecture: string;
classes: string;
modelSha256: string;
inputShape: readonly number[];
};
metrics: {
reviewFrameCount: number;
recordedCurrentFrameCount: number;
recoveredCameraFrameCount: number;
semanticFrameCoverageFraction: number;
detectionCount: number;
rangedDetectionCount: number;
cameraOnlyDetectionCount: number;
rangeCoverageFraction: number;
minimumDetectorScore: number;
cuboidCount: number;
routeDetectionCount: number;
routeDetectionFrameCount: number;
framesProcessed: number;
effectiveComposedFps: number;
composedP95Ms: number;
semanticEffectiveFps: number;
lidarFusedFrames: number;
rawAcceptedCuboids: number;
duplicateSupportRejections: number;
temporalAcceptedCuboids: number;
worldObjectObservations: number;
classCounts: Readonly<Record<string, number>>;
};
frames: readonly L33FrameSummary[];
limitations: readonly string[];
}
export interface L33Detection {
sourceTrackId: number;
label: string;
associationGroup: string;
score: number;
bboxXyxy: readonly [number, number, number, number];
semanticProvenance: L33SemanticProvenance;
geometryStatus: string;
geometryReason: string;
rangeM: number | null;
sourceE29OpticalDepthM: number | null;
support: {
projectedPoints: number;
occupiedPoints: number;
connectedOccupiedPoints: number;
};
geometryAnchorXyzM: readonly [number, number, number] | null;
cameraRayLidar: {
originXyzM: readonly [number, number, number];
directionXyz: readonly [number, number, number];
};
}
export interface L33VisualFrame {
frameId: string;
summary: L33FrameSummary;
cameraUrl: string;
cameraWidth: number;
cameraHeight: number;
pointsXyzi: readonly number[];
projectedPointsXyd: readonly number[];
detections: readonly L33Detection[];
predictionBoxes: readonly L3VisualBox[];
}
const RESULT_ID = /^l33-camera-first-detector-review-[a-f0-9]{64}$/;
const SOURCE_ID = /^(?:l32-pointpillars-camera-review|e10-integrated-perception|e29-camera-geometry|e10-lidar-pack|rectified-camera-world-state)-[a-f0-9]{64}$/;
const FRAME_ID = /^[0-9]{6}$/;
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`);
}
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`);
}
return value;
}
function exact<T extends string>(value: unknown, expected: T, label: string): T {
if (value !== expected) {
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
}
return expected;
}
function number(value: unknown, label: string, minimum = 0): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) {
throw new AdvancedLaboratoryContractError(`${label}: неверное число.`);
}
return value;
}
function integer(value: unknown, label: string): number {
const parsed = number(value, label);
if (!Number.isInteger(parsed)) {
throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое.`);
}
return parsed;
}
function provenance(value: unknown, label: string): L33SemanticProvenance {
if (
value !== "recorded-current"
&& value !== "bounded-track-interpolation"
&& value !== "rectified-detector-current"
&& value !== "e23-temporal-hold"
) {
throw new AdvancedLaboratoryContractError(`${label}: неизвестный источник.`);
}
return value;
}
function tuple(
value: unknown,
length: number,
label: string,
minimum = -Infinity,
): readonly number[] {
const values = array(value, label).map((item, index) => (
number(item, `${label}[${index}]`, minimum)
));
if (values.length !== length) {
throw new AdvancedLaboratoryContractError(`${label}: неверная размерность.`);
}
return values;
}
function sourceId(value: unknown, label: string): string {
const parsed = string(value, label);
if (!SOURCE_ID.test(parsed)) {
throw new AdvancedLaboratoryContractError(`${label}: неверная identity.`);
}
return parsed;
}
function parseSummary(value: unknown): L33FrameSummary {
const item = record(value, "L3.3 frame");
const frameId = string(item.frame_id, "L3.3 frame id");
if (!FRAME_ID.test(frameId)) {
throw new AdvancedLaboratoryContractError("L3.3 frame id: неверный формат.");
}
return {
frameId,
frameIndex: integer(item.frame_index, "L3.3 frame index"),
sessionSeconds: number(item.session_seconds, "L3.3 session seconds"),
cameraSourceFrameIndex: integer(item.camera_source_frame_index, "L3.3 camera frame"),
cameraSessionSeconds: number(item.camera_session_seconds, "L3.3 camera seconds"),
cameraDeltaMs: number(item.camera_delta_ms, "L3.3 camera delta", -100),
sourcePointCount: integer(item.source_point_count, "L3.3 source points"),
detectionCount: integer(item.detection_count, "L3.3 detections"),
rangedDetectionCount: integer(item.ranged_detection_count, "L3.3 ranged detections"),
cameraOnlyDetectionCount: integer(item.camera_only_detection_count, "L3.3 camera-only detections"),
cuboidCount: item.cuboid_count === undefined
? 0
: integer(item.cuboid_count, "L3.3 cuboids"),
semanticProvenance: provenance(item.semantic_provenance, "L3.3 provenance"),
};
}
function parsePredictionBox(value: unknown): L3VisualBox {
const item = record(value, "L3.3 cuboid");
exact(item.status, "model-prediction", "L3.3 cuboid status");
return {
benchmarkClass: string(item.benchmark_class, "L3.3 cuboid class"),
centerXyzM: tuple(item.center_xyz_m, 3, "L3.3 cuboid center", -Infinity) as [number, number, number],
sizeLwhM: tuple(item.size_lwh_m, 3, "L3.3 cuboid size", Number.MIN_VALUE) as [number, number, number],
yawRad: number(item.yaw_rad, "L3.3 cuboid yaw", -Infinity),
status: "model-prediction",
score: number(item.score, "L3.3 cuboid score"),
temporalStatus: string(item.temporal_status, "L3.3 cuboid temporal status"),
};
}
function parseDetection(value: unknown): L33Detection {
const item = record(value, "L3.3 detection");
const support = record(item.support, "L3.3 support");
const cameraRay = record(item.camera_ray_lidar, "L3.3 camera ray");
return {
sourceTrackId: integer(item.source_track_id, "L3.3 track"),
label: string(item.label, "L3.3 label"),
associationGroup: string(item.association_group, "L3.3 group"),
score: number(item.score, "L3.3 score", 0.25),
bboxXyxy: tuple(item.bbox_xyxy, 4, "L3.3 bbox", -Infinity) as [number, number, number, number],
semanticProvenance: provenance(item.semantic_provenance, "L3.3 detection provenance"),
geometryStatus: string(item.geometry_status, "L3.3 geometry status"),
geometryReason: string(item.geometry_reason, "L3.3 geometry reason"),
rangeM: item.range_m === null ? null : number(item.range_m, "L3.3 range"),
sourceE29OpticalDepthM: item.source_e29_optical_depth_m === null
? null
: number(item.source_e29_optical_depth_m, "L3.3 E29 optical depth"),
support: {
projectedPoints: integer(support.projected_points, "L3.3 projected support"),
occupiedPoints: integer(support.occupied_points, "L3.3 occupied support"),
connectedOccupiedPoints: integer(support.connected_occupied_points, "L3.3 connected support"),
},
geometryAnchorXyzM: item.geometry_anchor_xyz_m === null
? null
: tuple(item.geometry_anchor_xyz_m, 3, "L3.3 geometry anchor", -Infinity) as [number, number, number],
cameraRayLidar: {
originXyzM: tuple(cameraRay.origin_xyz_m, 3, "L3.3 ray origin", -Infinity) as [number, number, number],
directionXyz: tuple(cameraRay.direction_xyz, 3, "L3.3 ray direction", -Infinity) as [number, number, number],
},
};
}
function parseResult(value: unknown): L33CameraFirstDetectorReviewResult {
const item = record(value, "L3.3 result");
exact(item.schema_version, "missioncore.l33-camera-first-detector-review-result/v1", "L3.3 schema");
exact(item.access, "read-only", "L3.3 access");
const resultId = string(item.result_id, "L3.3 result id");
if (!RESULT_ID.test(resultId)) {
throw new AdvancedLaboratoryContractError("L3.3 result: неверная identity.");
}
const detector = record(item.detector, "L3.3 detector");
const metrics = record(item.metrics, "L3.3 metrics");
const classCounts = record(metrics.class_counts, "L3.3 class counts");
const frames = array(item.frames, "L3.3 frames").map(parseSummary);
if (!frames.length || frames.length > 18) {
throw new AdvancedLaboratoryContractError("L3.3 frames: нарушен bound.");
}
return {
resultId,
createdAtUtc: string(item.created_at_utc, "L3.3 created at"),
status: exact(item.status, "camera-first-boundary-restored-shadow-only", "L3.3 status"),
sourceSessionId: exact(item.source_session_id, "20260720T065719Z_viewer_live", "L3.3 session"),
sourceL32ResultId: sourceId(item.source_l32_result_id, "L3.3 L3.2 id"),
sourceE26ResultId: sourceId(item.source_e26_result_id, "L3.3 E26 id"),
sourceE29ResultId: sourceId(item.source_e29_result_id, "L3.3 E29 id"),
sourceE10PackId: sourceId(item.source_e10_pack_id, "L3.3 E10 id"),
sourceWorldStateResultId: sourceId(item.source_world_state_result_id, "L3.3 world-state id"),
detector: {
id: string(detector.id, "L3.3 detector id"),
architecture: string(detector.architecture, "L3.3 detector architecture"),
classes: string(detector.classes, "L3.3 detector classes"),
modelSha256: string(detector.model_sha256, "L3.3 detector sha"),
inputShape: tuple(detector.input_shape, 4, "L3.3 detector shape"),
},
metrics: {
reviewFrameCount: integer(metrics.review_frame_count, "L3.3 review frames"),
recordedCurrentFrameCount: integer(metrics.recorded_current_frame_count, "L3.3 current frames"),
recoveredCameraFrameCount: integer(metrics.recovered_camera_frame_count, "L3.3 recovered frames"),
semanticFrameCoverageFraction: number(metrics.semantic_frame_coverage_fraction, "L3.3 semantic coverage"),
detectionCount: integer(metrics.detection_count, "L3.3 detections"),
rangedDetectionCount: integer(metrics.ranged_detection_count, "L3.3 ranged"),
cameraOnlyDetectionCount: integer(metrics.camera_only_detection_count, "L3.3 camera only"),
rangeCoverageFraction: number(metrics.range_coverage_fraction, "L3.3 range coverage"),
minimumDetectorScore: number(metrics.minimum_detector_score, "L3.3 threshold"),
cuboidCount: integer(metrics.cuboid_count, "L3.3 selected cuboids"),
routeDetectionCount: integer(metrics.route_detection_count, "L3.3 route detections"),
routeDetectionFrameCount: integer(metrics.route_detection_frame_count, "L3.3 route detection frames"),
framesProcessed: integer(metrics.frames_processed, "L3.3 processed frames"),
effectiveComposedFps: number(metrics.effective_composed_fps, "L3.3 composed FPS", Number.MIN_VALUE),
composedP95Ms: number(metrics.composed_p95_ms, "L3.3 composed p95", Number.MIN_VALUE),
semanticEffectiveFps: number(metrics.semantic_effective_fps, "L3.3 semantic FPS", Number.MIN_VALUE),
lidarFusedFrames: integer(metrics.lidar_fused_frames, "L3.3 fused frames"),
rawAcceptedCuboids: integer(metrics.raw_accepted_cuboids, "L3.3 raw cuboids"),
duplicateSupportRejections: integer(
metrics.duplicate_support_rejections,
"L3.3 duplicate support rejections",
),
temporalAcceptedCuboids: integer(metrics.temporal_accepted_cuboids, "L3.3 temporal cuboids"),
worldObjectObservations: integer(metrics.world_object_observations, "L3.3 world objects"),
classCounts: Object.fromEntries(
Object.entries(classCounts).map(([key, entry]) => [key, integer(entry, `L3.3 class ${key}`)]),
),
},
frames,
limitations: array(item.limitations, "L3.3 limitations").map((entry) => string(entry, "L3.3 limitation")),
};
}
export async function fetchL33CameraFirstDetectorReview({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<L33CameraFirstDetectorReviewResult | null> {
const response = await fetcher(
"/api/v1/laboratory/l33/camera-first-detector-review/results?limit=1",
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new AdvancedLaboratoryContractError(`L3.3 недоступен: HTTP ${response.status}.`);
}
const catalog = record(await response.json(), "L3.3 catalog");
exact(catalog.schema_version, "missioncore.l33-camera-first-detector-review-catalog-results/v1", "L3.3 catalog schema");
const items = array(catalog.items, "L3.3 catalog items");
return items.length ? parseResult(items[0]) : null;
}
export async function fetchL33CameraFirstDetectorReviewFrame(
resultId: string,
frameId: string,
{
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {},
): Promise<L33VisualFrame> {
if (!RESULT_ID.test(resultId) || !FRAME_ID.test(frameId)) {
throw new AdvancedLaboratoryContractError("L3.3 frame: неверная identity.");
}
const response = await fetcher(
`/api/v1/laboratory/l33/camera-first-detector-review/${resultId}/frames/${frameId}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new AdvancedLaboratoryContractError(`L3.3 frame недоступен: HTTP ${response.status}.`);
}
const item = record(await response.json(), "L3.3 visual frame");
exact(item.schema_version, "missioncore.l33-camera-first-detector-review-frame/v1", "L3.3 frame schema");
const summary = parseSummary(item.summary);
const camera = record(item.camera, "L3.3 camera");
const points = record(item.points, "L3.3 points");
exact(points.layout, "flat-xyzi", "L3.3 point layout");
const projection = record(item.camera_projection, "L3.3 projection");
exact(projection.point_layout, "flat-xy-depth-m", "L3.3 projection layout");
const projected = array(projection.point_values, "L3.3 projected points").map(
(entry, index) => number(entry, `L3.3 projected[${index}]`, -Infinity),
);
if (projected.length !== integer(projection.point_count, "L3.3 projected count") * 3) {
throw new AdvancedLaboratoryContractError("L3.3 projection: нарушен размер.");
}
const detections = array(item.detections, "L3.3 detections").map(parseDetection);
const predictionBoxes = array(item.prediction_boxes, "L3.3 cuboids").map(parsePredictionBox);
return {
frameId,
summary,
cameraUrl: `/api/v1/laboratory/l33/camera-first-detector-review/${resultId}/frames/${frameId}/camera`,
cameraWidth: integer(camera.width, "L3.3 camera width"),
cameraHeight: integer(camera.height, "L3.3 camera height"),
pointsXyzi: array(points.values, "L3.3 point values").map(
(entry, index) => number(entry, `L3.3 points[${index}]`, -Infinity),
),
projectedPointsXyd: projected,
detections,
predictionBoxes,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,436 @@
export interface L34FrameSummary {
truthIslandSequence: number;
imageId: number;
frameIndex: number;
groupId: string;
predictionCount: number;
maximumScore: number;
}
export type L34PredictionLabel =
| "person"
| "bicycle"
| "motorcycle"
| "car"
| "heavy_vehicle"
| "static_obstacle"
| "animal";
export interface L34Prediction {
label: L34PredictionLabel;
score: number;
bboxXyxy: readonly [number, number, number, number];
}
export interface L34VisualFrame {
resultId: string;
truthIslandSequence: number;
imageId: number;
frameIndex: number;
groupId: string;
role: "anchor" | "temporal";
sessionSeconds: number;
cameraUrl: string;
cameraWidth: number;
cameraHeight: number;
cameraSha256: string;
predictionRowsSha256: string;
predictions: readonly L34Prediction[];
truthLabelsRead: false;
}
export interface L34RightYoloxTruthIslandResult {
resultId: string;
createdAtUtc: string;
status: "predictions-frozen-awaiting-independent-truth";
profileId: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1";
pipelineId: string;
sourceSessionId: string;
cameraSourceId: "sensor.camera.right";
candidate: {
architecture: "YOLOX-S";
modelSha256: string;
minimumScore: number;
l33ResultId: string;
};
truthIsland: {
resultId: string;
truthState: "labels-unavailable";
};
metrics: {
frameCount: number;
temporalGroupCount: number;
predictionCount: number;
framesWithPredictions: number;
classCounts: Readonly<Record<string, number>>;
accuracyMetricsAvailable: false;
};
frames: readonly L34FrameSummary[];
decision: {
candidatePredictionsFrozen: true;
truthLabelsRead: false;
candidateAccepted: false;
modelRetrainingAuthorized: false;
nextGate: string;
};
limitations: readonly string[];
access: "read-only";
}
class L34ContractError extends Error {}
type LaboratoryFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new L34ContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): readonly unknown[] {
if (!Array.isArray(value)) {
throw new L34ContractError(`${label}: ожидался массив.`);
}
return value;
}
function string(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new L34ContractError(`${label}: ожидалась строка.`);
}
return value;
}
function integer(value: unknown, label: string): number {
if (!Number.isInteger(value) || Number(value) < 0) {
throw new L34ContractError(`${label}: ожидалось целое число.`);
}
return Number(value);
}
function finiteNumber(
value: unknown,
label: string,
minimum = 0,
): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) {
throw new L34ContractError(`${label}: ожидалось конечное число.`);
}
return value;
}
function exact<T extends string | boolean>(
value: unknown,
expected: T,
label: string,
): T {
if (value !== expected) throw new L34ContractError(`${label}: нарушен контракт.`);
return expected;
}
function predictionLabel(value: unknown, label: string): L34PredictionLabel {
if (
value !== "person"
&& value !== "bicycle"
&& value !== "motorcycle"
&& value !== "car"
&& value !== "heavy_vehicle"
&& value !== "static_obstacle"
&& value !== "animal"
) {
throw new L34ContractError(`${label}: неизвестный класс.`);
}
return value;
}
function parseFrameSummary(value: unknown): L34FrameSummary {
const item = object(value, "L3.4 frame summary");
const maximumScore = finiteNumber(
item.maximum_score,
"L3.4 frame maximum score",
);
if (maximumScore > 1) {
throw new L34ContractError("L3.4 frame maximum score: нарушен диапазон.");
}
return {
truthIslandSequence: integer(
item.truth_island_sequence,
"L3.4 frame sequence",
),
imageId: integer(item.image_id, "L3.4 frame image"),
frameIndex: integer(item.frame_index, "L3.4 frame index"),
groupId: string(item.group_id, "L3.4 frame group"),
predictionCount: integer(
item.prediction_count,
"L3.4 frame predictions",
),
maximumScore,
};
}
function parse(itemValue: unknown): L34RightYoloxTruthIslandResult {
const item = object(itemValue, "L3.4");
const candidate = object(item.candidate, "L3.4.candidate");
const truthIsland = object(item.truth_island, "L3.4.truth_island");
const metrics = object(item.metrics, "L3.4.metrics");
const classCounts = object(metrics.class_counts, "L3.4.metrics.class_counts");
const decision = object(item.decision, "L3.4.decision");
const frames = array(item.frames, "L3.4.frames").map(parseFrameSummary);
const resultId = string(item.result_id, "L3.4.result_id");
const modelSha256 = string(candidate.model_sha256, "L3.4.candidate.model_sha256");
if (
!/^l34-right-yolox-truth-island-freeze-[a-f0-9]{64}$/.test(resultId)
|| !/^[a-f0-9]{64}$/.test(modelSha256)
) {
throw new L34ContractError("L3.4: нарушена content identity.");
}
const frameCount = integer(metrics.frame_count, "L3.4.metrics.frame_count");
const predictionCount = integer(
metrics.prediction_count,
"L3.4.metrics.prediction_count",
);
const framesWithPredictions = integer(
metrics.frames_with_predictions,
"L3.4.metrics.frames_with_predictions",
);
if (
!frames.length
|| frames.length > 32
|| frames.length !== frameCount
|| new Set(frames.map(({ truthIslandSequence }) => truthIslandSequence)).size
!== frames.length
|| frames.reduce((total, frame) => total + frame.predictionCount, 0)
!== predictionCount
|| frames.filter(({ predictionCount: count }) => count > 0).length
!== framesWithPredictions
) {
throw new L34ContractError("L3.4.frames: нарушена визуальная связность.");
}
const counts = Object.fromEntries(Object.entries(classCounts).map(([key, value]) => (
[key, integer(value, `L3.4.metrics.class_counts.${key}`)]
)));
const limitations = item.limitations;
if (!Array.isArray(limitations) || limitations.some((value) => typeof value !== "string")) {
throw new L34ContractError("L3.4.limitations: ожидался список строк.");
}
const minimumScore = candidate.minimum_score;
if (typeof minimumScore !== "number" || minimumScore !== 0.25) {
throw new L34ContractError("L3.4.candidate.minimum_score: нарушен контракт.");
}
return {
resultId,
createdAtUtc: string(item.created_at_utc, "L3.4.created_at_utc"),
status: exact(
item.status,
"predictions-frozen-awaiting-independent-truth",
"L3.4.status",
),
profileId: exact(
item.profile_id,
"RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
"L3.4.profile_id",
),
pipelineId: string(item.pipeline_id, "L3.4.pipeline_id"),
sourceSessionId: string(item.source_session_id, "L3.4.source_session_id"),
cameraSourceId: exact(
item.camera_source_id,
"sensor.camera.right",
"L3.4.camera_source_id",
),
candidate: {
architecture: exact(candidate.architecture, "YOLOX-S", "L3.4.architecture"),
modelSha256,
minimumScore,
l33ResultId: string(candidate.l33_result_id, "L3.4.candidate.l33_result_id"),
},
truthIsland: {
resultId: string(truthIsland.result_id, "L3.4.truth_island.result_id"),
truthState: exact(
truthIsland.truth_state,
"labels-unavailable",
"L3.4.truth_island.truth_state",
),
},
metrics: {
frameCount,
temporalGroupCount: integer(
metrics.temporal_group_count,
"L3.4.metrics.temporal_group_count",
),
predictionCount,
framesWithPredictions,
classCounts: counts,
accuracyMetricsAvailable: exact(
metrics.accuracy_metrics_available,
false,
"L3.4.metrics.accuracy_metrics_available",
),
},
frames,
decision: {
candidatePredictionsFrozen: exact(
decision.candidate_predictions_frozen,
true,
"L3.4.decision.candidate_predictions_frozen",
),
truthLabelsRead: exact(
decision.truth_labels_read,
false,
"L3.4.decision.truth_labels_read",
),
candidateAccepted: exact(
decision.candidate_accepted,
false,
"L3.4.decision.candidate_accepted",
),
modelRetrainingAuthorized: exact(
decision.model_retraining_authorized,
false,
"L3.4.decision.model_retraining_authorized",
),
nextGate: string(decision.next_gate, "L3.4.decision.next_gate"),
},
limitations: limitations as string[],
access: exact(item.access, "read-only", "L3.4.access"),
};
}
function parseVisualFrame(
value: unknown,
resultId: string,
sequence: number,
): L34VisualFrame {
const item = object(value, "L3.4 visual frame");
exact(
item.schema_version,
"missioncore.l34-right-yolox-truth-island-frame/v1",
"L3.4 frame schema",
);
exact(item.access, "read-only", "L3.4 frame access");
exact(item.truth_labels_read, false, "L3.4 frame truth state");
if (
item.result_id !== resultId
|| item.truth_island_sequence !== sequence
|| !Number.isInteger(sequence)
|| sequence < 1
|| sequence > 32
) {
throw new L34ContractError("L3.4 visual frame: нарушена identity.");
}
const camera = object(item.camera, "L3.4 frame camera");
const cameraWidth = integer(camera.width, "L3.4 camera width");
const cameraHeight = integer(camera.height, "L3.4 camera height");
const cameraSha256 = string(camera.sha256, "L3.4 camera sha256");
const predictionRowsSha256 = string(
item.prediction_rows_sha256,
"L3.4 prediction rows sha256",
);
if (
!/^[a-f0-9]{64}$/.test(cameraSha256)
|| !/^[a-f0-9]{64}$/.test(predictionRowsSha256)
) {
throw new L34ContractError("L3.4 visual frame: нарушена content identity.");
}
const predictions = array(item.predictions, "L3.4 frame predictions").map(
(value, index): L34Prediction => {
const prediction = object(value, `L3.4 prediction ${index}`);
const score = finiteNumber(prediction.score, `L3.4 prediction ${index} score`, 0.25);
const bbox = array(prediction.bbox_xyxy, `L3.4 prediction ${index} bbox`).map(
(entry, coordinate) => finiteNumber(
entry,
`L3.4 prediction ${index} bbox ${coordinate}`,
),
);
if (
score > 1
|| bbox.length !== 4
|| !(bbox[0] < bbox[2] && bbox[2] <= cameraWidth)
|| !(bbox[1] < bbox[3] && bbox[3] <= cameraHeight)
) {
throw new L34ContractError(`L3.4 prediction ${index}: нарушена геометрия.`);
}
return {
label: predictionLabel(prediction.label, `L3.4 prediction ${index} label`),
score,
bboxXyxy: bbox as [number, number, number, number],
};
},
);
const role = item.role;
if (role !== "anchor" && role !== "temporal") {
throw new L34ContractError("L3.4 frame role: нарушен контракт.");
}
return {
resultId,
truthIslandSequence: sequence,
imageId: integer(item.image_id, "L3.4 frame image"),
frameIndex: integer(item.frame_index, "L3.4 frame index"),
groupId: string(item.group_id, "L3.4 frame group"),
role,
sessionSeconds: finiteNumber(item.session_seconds, "L3.4 frame time"),
cameraUrl: `/api/v1/laboratory/l34/results/${resultId}/frames/${sequence}/camera`,
cameraWidth,
cameraHeight,
cameraSha256,
predictionRowsSha256,
predictions,
truthLabelsRead: false,
};
}
export async function fetchL34RightYoloxTruthIsland({
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {}): Promise<L34RightYoloxTruthIslandResult | null> {
const response = await fetcher("/api/v1/laboratory/l34/results?limit=1", {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) throw new L34ContractError(`L3.4 недоступен: HTTP ${response.status}.`);
const catalog = object(await response.json(), "L3.4 catalog");
exact(
catalog.schema_version,
"missioncore.l34-right-yolox-truth-island-catalog/v1",
"L3.4 catalog.schema_version",
);
exact(catalog.access, "read-only", "L3.4 catalog.access");
if (!Array.isArray(catalog.items) || catalog.items.length > 1) {
throw new L34ContractError("L3.4 catalog.items: нарушен размер.");
}
return catalog.items.length ? parse(catalog.items[0]) : null;
}
export async function fetchL34RightYoloxTruthIslandFrame(
resultId: string,
sequence: number,
{
fetcher = fetch,
signal,
}: {
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
} = {},
): Promise<L34VisualFrame> {
if (!/^l34-right-yolox-truth-island-freeze-[a-f0-9]{64}$/.test(resultId)) {
throw new L34ContractError("L3.4 visual frame: неверный result id.");
}
if (!Number.isInteger(sequence) || sequence < 1 || sequence > 32) {
throw new L34ContractError("L3.4 visual frame: неверный sequence.");
}
const response = await fetcher(
`/api/v1/laboratory/l34/results/${resultId}/frames/${sequence}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
if (!response.ok) {
throw new L34ContractError(`L3.4 visual frame недоступен: HTTP ${response.status}.`);
}
return parseVisualFrame(await response.json(), resultId, sequence);
}

Some files were not shown because too many files have changed in this diff Show More