diff --git a/apps/control-station/package-lock.json b/apps/control-station/package-lock.json index 4a680ed..7e0587f 100644 --- a/apps/control-station/package-lock.json +++ b/apps/control-station/package-lock.json @@ -15,12 +15,14 @@ "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@rerun-io/web-viewer": "0.34.1", "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", @@ -344,6 +346,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", @@ -1223,6 +1232,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", @@ -1305,6 +1321,35 @@ "@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==", + "dev": true, + "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", @@ -1503,6 +1548,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", @@ -1571,6 +1623,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", @@ -1758,6 +1817,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", diff --git a/apps/control-station/package.json b/apps/control-station/package.json index 3c5e022..a834759 100644 --- a/apps/control-station/package.json +++ b/apps/control-station/package.json @@ -18,12 +18,14 @@ "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@rerun-io/web-viewer": "0.34.1", "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", diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 95ae3ed..75b137a 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -47,6 +47,7 @@ import type { import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission"; import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile"; import { resolvePolygonRunRoute } from "./core/polygon/runArchive"; +import { usePolygonWorkerCapability } from "./core/polygon/usePolygonWorkerCapability"; import { OBSERVATION_WORKSPACE_ID, OBSERVATION_WORKSPACE_LAYOUT_VERSION, @@ -54,7 +55,7 @@ import { } from "./core/observation/workspaceLayout"; import { rootById, - roots, + rootsForCapabilities, workspaceById, workspacesForRoot, type RootId, @@ -150,6 +151,7 @@ function mergeViewerSettings( export default function App() { const runtime = useMissionRuntime(); const { selection } = useDevicePluginHost(); + const polygonWorker = usePolygonWorkerCapability(); const polygonRunRoute = useMemo( () => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search), [], @@ -160,7 +162,7 @@ export default function App() { }); const [activeRoot, setActiveRoot] = useState( - polygonRunRoute.active ? "system" : null, + polygonRunRoute.active ? "polygon" : null, ); const [sourceUrl, setSourceUrl] = useState(""); const [recordedReplay, setRecordedReplay] = useState(null); @@ -197,6 +199,10 @@ export default function App() { const polygonRunRouteOpenedRef = useRef(false); const currentRoot = rootById(activeRoot); + const visibleRoots = useMemo( + () => rootsForCapabilities({ polygonWorkerAvailable: polygonWorker.available }), + [polygonWorker.available], + ); const activeDefinition = workspaceById(workspace.activeView); const spatialWorkspaceActive = Boolean( workspace.contentOpen && activeDefinition?.kind === "spatial", @@ -215,7 +221,7 @@ export default function App() { useEffect(() => { if (!polygonRunRoute.active || polygonRunRouteOpenedRef.current) return; polygonRunRouteOpenedRef.current = true; - workspace.openView("polygon-run-internal"); + workspace.openView("polygon-run"); }, [polygonRunRoute.active, workspace]); if (!sceneSettingsCommitterRef.current) { @@ -413,6 +419,11 @@ export default function App() { const selectRoot = (rootId: RootId) => { setActiveRoot(rootId); + if (rootId === "polygon") { + workspace.closeNavigation(); + workspace.openView("polygon-run"); + return; + } workspace.closeView(); workspace.openNavigation(); }; @@ -618,7 +629,7 @@ export default function App() { ({ value: root.id, label: root.label }))} + items={visibleRoots.map((root) => ({ value: root.id, label: root.label }))} onChange={selectRoot} /> @@ -733,7 +744,7 @@ export default function App() { ) : null} ) : activeDefinition.kind === "polygon-run" ? ( - Только чтение + Virtual only ) : ( Интерфейс готов ) diff --git a/apps/control-station/src/core/polygon/usePolygonWorkerCapability.ts b/apps/control-station/src/core/polygon/usePolygonWorkerCapability.ts new file mode 100644 index 0000000..1e790a6 --- /dev/null +++ b/apps/control-station/src/core/polygon/usePolygonWorkerCapability.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from "react"; + +import { + fetchPolygonWorkerStatus, + type PolygonWorkerStatus, +} from "./liveWorker"; + +export interface PolygonWorkerCapability { + checked: boolean; + available: boolean; + status: PolygonWorkerStatus | null; +} + +const initialCapability: PolygonWorkerCapability = { + checked: false, + available: false, + status: null, +}; + +export function usePolygonWorkerCapability(): PolygonWorkerCapability { + const [capability, setCapability] = useState(initialCapability); + + useEffect(() => { + const controller = new AbortController(); + let timer: number | null = null; + + const poll = async () => { + try { + const status = await fetchPolygonWorkerStatus({ signal: controller.signal }); + if (controller.signal.aborted) return; + setCapability({ + checked: true, + available: status.available, + status, + }); + } catch { + if (controller.signal.aborted) return; + setCapability({ + checked: true, + available: false, + status: null, + }); + } finally { + if (!controller.signal.aborted) { + timer = window.setTimeout(() => void poll(), 3_000); + } + } + }; + + void poll(); + return () => { + controller.abort(); + if (timer !== null) window.clearTimeout(timer); + }; + }, []); + + return capability; +} diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index 7ac16db..86f18bc 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -1,6 +1,13 @@ import type { IconName } from "@nodedc/ui-react"; -export type RootId = "center" | "fleet" | "observation" | "missions" | "data" | "system"; +export type RootId = + | "center" + | "fleet" + | "observation" + | "missions" + | "data" + | "polygon" + | "system"; export type WorkspaceKind = | "overview" @@ -120,6 +127,15 @@ export const roots: RootDefinition[] = [ statement: "Хранить живой контур и воспроизводимый эксперимент как одну модель данных.", accent: "ПОТОКИ И ДОКАЗАТЕЛЬСТВА", }, + { + id: "polygon", + label: "Полигон", + title: "Полигон", + eyebrow: "СИМУЛЯЦИЯ И КВАЛИФИКАЦИЯ", + description: "Живые PX4/Gazebo прогоны, сценарии, траектории и доказательства.", + statement: "Проверять поведение машины в воспроизводимой виртуальной среде.", + accent: "СИМУЛЯЦИЯ И ДОКАЗАТЕЛЬСТВА", + }, { id: "system", label: "Система", @@ -133,15 +149,14 @@ export const roots: RootDefinition[] = [ export const workspaces: WorkspaceDefinition[] = [ { - id: "polygon-run-internal", - root: "system", + id: "polygon-run", + root: "polygon", label: "Полигон", title: "Полигон", - eyebrow: "ПОЛИГОН / UI-1", + eyebrow: "ПОЛИГОН / LIVE", description: "Live Simulation Worker и доказательства прогонов PX4/Gazebo.", icon: "activity", kind: "polygon-run", - internalOnly: true, groups: [], }, { @@ -756,6 +771,14 @@ export function rootById(id: RootId | null): RootDefinition | null { return id ? roots.find((root) => root.id === id) ?? null : null; } +export function rootsForCapabilities({ + polygonWorkerAvailable, +}: { + polygonWorkerAvailable: boolean; +}): RootDefinition[] { + return roots.filter((root) => root.id !== "polygon" || polygonWorkerAvailable); +} + export function workspaceById(id: string | null): WorkspaceDefinition | null { return id ? workspaces.find((workspace) => workspace.id === id) ?? null : null; } diff --git a/apps/control-station/src/styles/responsive.css b/apps/control-station/src/styles/responsive.css index 1c487aa..cf1ca12 100644 --- a/apps/control-station/src/styles/responsive.css +++ b/apps/control-station/src/styles/responsive.css @@ -166,8 +166,13 @@ flex-direction: column; } - .polygon-live-map { - min-height: 15rem; + .polygon-rover-scene { + min-height: 19rem; + } + + .polygon-rover-scene__frames small, + .polygon-rover-scene__help { + display: none; } .polygon-run-event-list article { diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index dc11cb1..50439b9 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -138,84 +138,160 @@ gap: 0.75rem; } -.polygon-live-map { +.polygon-rover-scene { position: relative; overflow: hidden; - min-height: 19rem; + min-height: 25rem; border: 1px solid var(--station-hairline); border-radius: 1rem; background: - radial-gradient(circle at 48% 46%, rgb(var(--nodedc-accent-rgb) / 0.055), transparent 45%), - rgb(255 255 255 / 0.018); + radial-gradient(circle at 62% 28%, rgb(var(--nodedc-accent-rgb) / 0.13), transparent 42%), + linear-gradient(155deg, #0d1722, #070c12 70%); } -.polygon-live-map svg { +.polygon-rover-scene--loading { + display: grid; + place-items: center; + color: var(--nodedc-text-muted); + font-size: 0.62rem; +} + +.polygon-rover-scene__viewport, +.polygon-rover-scene__viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; } -.polygon-live-map pattern path { - fill: none; - stroke: rgb(255 255 255 / 0.045); - stroke-width: 0.25; +.polygon-rover-scene__viewport canvas { + cursor: grab; + touch-action: none; } -.polygon-live-map text { - fill: var(--nodedc-text-muted); - font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; - font-size: 3px; +.polygon-rover-scene__viewport canvas:active { + cursor: grabbing; } -.polygon-live-axis-x, -.polygon-live-axis-y { - stroke: rgb(255 255 255 / 0.28); - stroke-width: 0.45; -} - -.polygon-live-trajectory { - fill: none; - stroke: rgb(var(--nodedc-accent-rgb) / 0.58); - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 0.65; -} - -.polygon-live-rover { - fill: rgb(var(--nodedc-accent-rgb)); - stroke: rgb(var(--nodedc-on-accent-rgb)); - stroke-width: 0.35; -} - -.polygon-live-map > div { +.polygon-rover-scene__toolbar, +.polygon-rover-scene__help, +.polygon-rover-scene__frames, +.polygon-rover-scene__idle { position: absolute; - top: 50%; - left: 50%; - display: grid; - width: min(22rem, 76%); - gap: 0.35rem; - text-align: center; - transform: translate(-50%, -50%); + z-index: 2; } -.polygon-live-map > div strong { +.polygon-rover-scene__toolbar { + top: 0.75rem; + left: 0.75rem; + display: flex; + gap: 0.4rem; +} + +.polygon-rover-scene__toolbar button { + border: 1px solid rgb(255 255 255 / 0.14); + border-radius: 999px; + background: rgb(7 12 18 / 0.76); + color: var(--nodedc-text-secondary); + padding: 0.38rem 0.62rem; + font: inherit; + font-size: 0.54rem; + line-height: 1; + backdrop-filter: blur(16px); + cursor: pointer; +} + +.polygon-rover-scene__toolbar button:hover { + border-color: rgb(var(--nodedc-accent-rgb) / 0.5); color: var(--nodedc-text-primary); - font-size: 0.78rem; } -.polygon-live-map > div span, -.polygon-live-map > small { +.polygon-rover-scene__help { + bottom: 0.75rem; + left: 0.75rem; + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.polygon-rover-scene__help span, +.polygon-rover-scene__frames, +.polygon-rover-scene__frames small { color: var(--nodedc-text-muted); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 0.58rem; line-height: 1.45; } -.polygon-live-map > small { - position: absolute; - right: 0.8rem; +.polygon-rover-scene__frames { + right: 0.75rem; bottom: 0.7rem; - font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + display: flex; + align-items: center; + gap: 0.45rem; +} + +.polygon-rover-scene__frames span { + display: inline-flex; + align-items: center; + gap: 0.2rem; +} + +.polygon-rover-scene__frames i { + width: 0.36rem; + height: 0.36rem; + border-radius: 50%; + background: #fff; +} + +.polygon-rover-scene__frames i[data-axis="east"] { + background: #ff5f6d; +} + +.polygon-rover-scene__frames i[data-axis="north"] { + background: #4ee19b; +} + +.polygon-rover-scene__frames i[data-axis="up"] { + background: #5d9cff; +} + +.polygon-rover-scene__frames small { + margin-left: 0.2rem; +} + +.polygon-rover-scene__idle { + top: 4.1rem; + right: 0.8rem; + display: grid; + max-width: min(18rem, 52%); + gap: 0.2rem; + border: 1px solid rgb(255 255 255 / 0.09); + border-radius: 0.72rem; + background: rgb(7 12 18 / 0.58); + padding: 0.55rem 0.7rem; + text-align: right; + backdrop-filter: blur(14px); + pointer-events: none; +} + +.polygon-rover-scene__idle strong { + color: var(--nodedc-text-primary); + font-size: 0.68rem; +} + +.polygon-rover-scene__idle span, +.polygon-rover-scene__error { + color: var(--nodedc-text-muted); + font-size: 0.56rem; + line-height: 1.45; +} + +.polygon-rover-scene__error { + position: absolute; + inset: 50% auto auto 50%; + margin: 0; + transform: translate(-50%, -50%); } .polygon-live-telemetry { diff --git a/apps/control-station/src/workspaces/PolygonLivePanel.tsx b/apps/control-station/src/workspaces/PolygonLivePanel.tsx index 1e8403f..1063276 100644 --- a/apps/control-station/src/workspaces/PolygonLivePanel.tsx +++ b/apps/control-station/src/workspaces/PolygonLivePanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { Button, GlassSurface, @@ -14,44 +14,16 @@ import { type PolygonWorkerStatus, } from "../core/polygon/liveWorker"; +const PolygonRoverScene = lazy(async () => { + const module = await import("./PolygonRoverScene"); + return { default: module.PolygonRoverScene }; +}); + function message(error: unknown): string { if (error instanceof Error && error.message.trim()) return error.message; return "Simulation Worker не подтвердил операцию."; } -function yawDegrees(state: PolygonVehicleState): number { - const { x, y, z, w } = state.orientation; - return Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180 / Math.PI; -} - -function trajectoryPoints(states: PolygonVehicleState[]): { - points: string; - roverX: number; - roverY: number; -} { - if (!states.length) return { points: "", roverX: 50, roverY: 50 }; - const xs = states.map(({ position }) => position.x); - const ys = states.map(({ position }) => position.y); - const minimumX = Math.min(...xs); - const maximumX = Math.max(...xs); - const minimumY = Math.min(...ys); - const maximumY = Math.max(...ys); - const span = Math.max(maximumX - minimumX, maximumY - minimumY, 4); - const centerX = (minimumX + maximumX) / 2; - const centerY = (minimumY + maximumY) / 2; - const project = (state: PolygonVehicleState) => ({ - x: 50 + ((state.position.x - centerX) / span) * 80, - y: 50 - ((state.position.y - centerY) / span) * 80, - }); - const projected = states.map(project); - const rover = projected[projected.length - 1]; - return { - points: projected.map(({ x, y }) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" "), - roverX: rover.x, - roverY: rover.y, - }; -} - export function PolygonLivePanel() { const [worker, setWorker] = useState(null); const [live, setLive] = useState(null); @@ -99,7 +71,6 @@ export function PolygonLivePanel() { }; }, [actionBusy, generation]); - const projected = useMemo(() => trajectoryPoints(trajectory), [trajectory]); const runActive = Boolean(worker?.activeRunId); const runAction = async () => { @@ -150,44 +121,19 @@ export function PolygonLivePanel() {
-
- - - - - - - - - - - - - - E - N - {projected.points && ( - - )} - {live && ( - - - - - )} - - {!live && ( -
- {worker?.available ? "Ровер не запущен" : "Нет связи с worker"} - После старта здесь появится ground-truth траектория из Gazebo. + + Загружаем 3D-сцену…
- )} - map_enu · base_link_flu · diagnostic ground truth -
+ } + > + +
diff --git a/apps/control-station/src/workspaces/PolygonRoverScene.tsx b/apps/control-station/src/workspaces/PolygonRoverScene.tsx new file mode 100644 index 0000000..3926fcf --- /dev/null +++ b/apps/control-station/src/workspaces/PolygonRoverScene.tsx @@ -0,0 +1,387 @@ +import { useEffect, useRef, useState } from "react"; +import * as THREE from "three"; +import { OrbitControls } from "three/addons/controls/OrbitControls.js"; + +import type { PolygonVehicleState } from "../core/polygon/liveWorker"; + +interface PolygonRoverSceneProps { + live: PolygonVehicleState | null; + trajectory: PolygonVehicleState[]; + workerAvailable: boolean; +} + +const CAMERA_POSITION = new THREE.Vector3(7.2, 5.6, 7.2); +const CAMERA_TARGET = new THREE.Vector3(0, 0.55, 0); + +function threePosition(state: PolygonVehicleState): THREE.Vector3 { + return new THREE.Vector3( + state.position.x, + Math.max(0, state.position.z), + -state.position.y, + ); +} + +function yawRadians(state: PolygonVehicleState): number { + const { x, y, z, w } = state.orientation; + return Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)); +} + +function createRover(): THREE.Group { + const rover = new THREE.Group(); + rover.name = "ackermann-rover"; + + const bodyMaterial = new THREE.MeshStandardMaterial({ + color: 0x2879ff, + metalness: 0.42, + roughness: 0.3, + }); + const darkMaterial = new THREE.MeshStandardMaterial({ + color: 0x111820, + metalness: 0.25, + roughness: 0.58, + }); + const trimMaterial = new THREE.MeshStandardMaterial({ + color: 0xb8c9dc, + metalness: 0.72, + roughness: 0.22, + }); + const glassMaterial = new THREE.MeshPhysicalMaterial({ + color: 0x72b7d8, + metalness: 0.05, + roughness: 0.08, + transmission: 0.3, + transparent: true, + opacity: 0.72, + }); + const tireMaterial = new THREE.MeshStandardMaterial({ + color: 0x080a0d, + metalness: 0.05, + roughness: 0.9, + }); + const headlightMaterial = new THREE.MeshStandardMaterial({ + color: 0xf2fbff, + emissive: 0x8ecbff, + emissiveIntensity: 2.4, + }); + const tailMaterial = new THREE.MeshStandardMaterial({ + color: 0xff3355, + emissive: 0xff1838, + emissiveIntensity: 1.7, + }); + + const addBox = ( + name: string, + size: [number, number, number], + position: [number, number, number], + material: THREE.Material, + ) => { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), material); + mesh.name = name; + mesh.position.set(...position); + mesh.castShadow = true; + mesh.receiveShadow = true; + rover.add(mesh); + return mesh; + }; + + addBox("lower-chassis", [2.55, 0.34, 1.32], [0, 0.58, 0], darkMaterial); + addBox("body-shell", [2.34, 0.42, 1.18], [0.02, 0.86, 0], bodyMaterial); + addBox("front-hood", [0.92, 0.24, 1.06], [0.69, 1.16, 0], bodyMaterial); + addBox("equipment-cabin", [0.82, 0.58, 1.0], [-0.35, 1.22, 0], darkMaterial); + addBox("windshield", [0.04, 0.42, 0.88], [0.08, 1.31, 0], glassMaterial) + .rotation.z = -0.18; + addBox("front-bumper", [0.16, 0.22, 1.48], [1.34, 0.56, 0], trimMaterial); + addBox("rear-bumper", [0.15, 0.2, 1.42], [-1.33, 0.55, 0], trimMaterial); + + for (const x of [-0.82, 0.82]) { + for (const z of [-0.76, 0.76]) { + const wheel = new THREE.Mesh( + new THREE.CylinderGeometry(0.42, 0.42, 0.28, 24), + tireMaterial, + ); + wheel.name = x > 0 ? "front-wheel" : "rear-wheel"; + wheel.position.set(x, 0.43, z); + wheel.rotation.x = Math.PI / 2; + wheel.castShadow = true; + rover.add(wheel); + + const hub = new THREE.Mesh( + new THREE.CylinderGeometry(0.16, 0.16, 0.3, 20), + trimMaterial, + ); + hub.position.copy(wheel.position); + hub.rotation.x = Math.PI / 2; + hub.castShadow = true; + rover.add(hub); + } + } + + for (const z of [-0.4, 0.4]) { + addBox("headlight", [0.08, 0.16, 0.22], [1.22, 0.9, z], headlightMaterial); + addBox("tail-light", [0.06, 0.15, 0.2], [-1.19, 0.88, z], tailMaterial); + } + + const mast = new THREE.Mesh( + new THREE.CylinderGeometry(0.045, 0.055, 0.7, 16), + trimMaterial, + ); + mast.position.set(-0.45, 1.85, 0); + mast.castShadow = true; + rover.add(mast); + + const lidar = new THREE.Mesh( + new THREE.CylinderGeometry(0.22, 0.22, 0.14, 32), + darkMaterial, + ); + lidar.name = "lidar"; + lidar.position.set(-0.45, 2.22, 0); + lidar.castShadow = true; + rover.add(lidar); + + const lidarBand = new THREE.Mesh( + new THREE.CylinderGeometry(0.225, 0.225, 0.045, 32), + glassMaterial, + ); + lidarBand.position.set(-0.45, 2.22, 0); + rover.add(lidarBand); + + return rover; +} + +function disposeScene(scene: THREE.Scene) { + scene.traverse((object) => { + if (!(object instanceof THREE.Mesh || object instanceof THREE.Line)) return; + object.geometry.dispose(); + const materials = Array.isArray(object.material) ? object.material : [object.material]; + materials.forEach((material) => material.dispose()); + }); +} + +export function PolygonRoverScene({ + live, + trajectory, + workerAvailable, +}: PolygonRoverSceneProps) { + const hostRef = useRef(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const roverRef = useRef(null); + const trajectoryRef = useRef | null>( + null, + ); + const desiredPositionRef = useRef(new THREE.Vector3()); + const desiredYawRef = useRef(0); + const followRef = useRef(true); + const [followRover, setFollowRover] = useState(true); + const [renderError, setRenderError] = useState(null); + + useEffect(() => { + desiredPositionRef.current.copy(live ? threePosition(live) : new THREE.Vector3()); + desiredYawRef.current = live ? -yawRadians(live) : 0; + }, [live]); + + useEffect(() => { + followRef.current = followRover; + }, [followRover]); + + useEffect(() => { + const line = trajectoryRef.current; + if (!line) return; + const points = trajectory.map((state) => { + const point = threePosition(state); + point.y += 0.06; + return point; + }); + line.geometry.setFromPoints(points); + line.geometry.computeBoundingSphere(); + }, [trajectory]); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + + let renderer: THREE.WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: true, + powerPreference: "high-performance", + }); + } catch { + setRenderError("Браузер не смог создать WebGL-сцену."); + return; + } + + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.06; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + renderer.domElement.setAttribute("aria-label", "Интерактивная 3D-сцена Ackermann Rover"); + host.prepend(renderer.domElement); + + const scene = new THREE.Scene(); + scene.fog = new THREE.FogExp2(0x081019, 0.025); + + const camera = new THREE.PerspectiveCamera(46, 1, 0.08, 220); + camera.position.copy(CAMERA_POSITION); + cameraRef.current = camera; + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.07; + controls.enablePan = true; + controls.enableZoom = true; + controls.minDistance = 2.2; + controls.maxDistance = 70; + controls.minPolarAngle = 0.04; + controls.maxPolarAngle = Math.PI - 0.04; + controls.target.copy(CAMERA_TARGET); + controls.update(); + controlsRef.current = controls; + + const hemisphere = new THREE.HemisphereLight(0xaed7ff, 0x10151b, 2.2); + scene.add(hemisphere); + const key = new THREE.DirectionalLight(0xffffff, 3.7); + key.position.set(5, 9, 4); + key.castShadow = true; + key.shadow.mapSize.set(1024, 1024); + key.shadow.camera.near = 1; + key.shadow.camera.far = 32; + key.shadow.camera.left = -10; + key.shadow.camera.right = 10; + key.shadow.camera.top = 10; + key.shadow.camera.bottom = -10; + scene.add(key); + const rim = new THREE.DirectionalLight(0x397dff, 2.1); + rim.position.set(-5, 3, -5); + scene.add(rim); + + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(160, 160), + new THREE.MeshStandardMaterial({ + color: 0x0b1118, + metalness: 0.12, + roughness: 0.9, + side: THREE.DoubleSide, + }), + ); + ground.name = "ground"; + ground.rotation.x = -Math.PI / 2; + ground.receiveShadow = true; + scene.add(ground); + + const grid = new THREE.GridHelper(160, 160, 0x2c7cff, 0x263746); + grid.position.y = 0.012; + const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material]; + gridMaterials.forEach((material) => { + material.transparent = true; + material.opacity = 0.34; + }); + scene.add(grid); + + const rover = createRover(); + roverRef.current = rover; + scene.add(rover); + + const line = new THREE.Line( + new THREE.BufferGeometry(), + new THREE.LineBasicMaterial({ + color: 0x44a6ff, + transparent: true, + opacity: 0.92, + }), + ); + line.name = "ground-truth-trajectory"; + trajectoryRef.current = line; + scene.add(line); + + 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(); + + const clock = new THREE.Clock(); + let animationFrame = 0; + const render = () => { + animationFrame = window.requestAnimationFrame(render); + const delta = Math.min(clock.getDelta(), 0.1); + const smoothing = 1 - Math.exp(-8 * delta); + rover.position.lerp(desiredPositionRef.current, smoothing); + rover.rotation.y = THREE.MathUtils.lerp(rover.rotation.y, desiredYawRef.current, smoothing); + if (followRef.current) { + const nextTarget = rover.position.clone(); + nextTarget.y += 0.72; + controls.target.lerp(nextTarget, smoothing); + } + controls.update(); + renderer.render(scene, camera); + }; + render(); + + return () => { + window.cancelAnimationFrame(animationFrame); + observer.disconnect(); + controls.dispose(); + renderer.dispose(); + renderer.domElement.remove(); + disposeScene(scene); + cameraRef.current = null; + controlsRef.current = null; + roverRef.current = null; + trajectoryRef.current = null; + }; + }, []); + + const resetCamera = () => { + const camera = cameraRef.current; + const controls = controlsRef.current; + if (!camera || !controls) return; + camera.position.copy(CAMERA_POSITION); + const rover = roverRef.current; + controls.target.copy(rover ? rover.position.clone().add(new THREE.Vector3(0, 0.55, 0)) : CAMERA_TARGET); + controls.update(); + }; + + return ( +
+
+ {renderError ?

{renderError}

: null} +
+
+ + +
+
+ ЛКМ · вращение + Колесо · масштаб + ПКМ · панорама +
+
+ E + N + U + map_enu · base_link_flu · ground truth +
+ {!live ? ( +
+ {workerAvailable ? "Ackermann Rover готов" : "Нет связи с worker"} + + {workerAvailable + ? "Запустите PX4/Gazebo — модель останется доступна для осмотра." + : "Полигон появится в шапке после подтверждения Simulation Worker."} + +
+ ) : null} +
+ ); +} diff --git a/apps/control-station/test/polygonRunArchive.test.mjs b/apps/control-station/test/polygonRunArchive.test.mjs index fc5730c..3c1534f 100644 --- a/apps/control-station/test/polygonRunArchive.test.mjs +++ b/apps/control-station/test/polygonRunArchive.test.mjs @@ -19,6 +19,7 @@ let stopPolygonWorker; let PolygonWorkerContractError; let workspaceById; let workspacesForRoot; +let rootsForCapabilities; before(async () => { server = await createServer({ @@ -43,7 +44,11 @@ before(async () => { stopPolygonWorker, PolygonWorkerContractError, } = await server.ssrLoadModule("/src/core/polygon/liveWorker.ts")); - ({ workspaceById, workspacesForRoot } = await server.ssrLoadModule("/src/productModel.ts")); + ({ + workspaceById, + workspacesForRoot, + rootsForCapabilities, + } = await server.ssrLoadModule("/src/productModel.ts")); }); after(async () => { @@ -218,7 +223,7 @@ function vehicleState(overrides = {}) { }; } -test("UI-0 route is direct-only and does not add Polygon to system navigation", () => { +test("Polygon is a worker-gated product root and keeps a compatible direct route", () => { assert.deepEqual(resolvePolygonRunRoute("?workspace=polygon-run"), { active: true, runId: null, @@ -237,11 +242,25 @@ test("UI-0 route is direct-only and does not add Polygon to system navigation", resolvePolygonRunRoute("?workspace=polygon-run&run=../../etc").error, /некорректный/, ); - assert.equal(workspaceById("polygon-run-internal").internalOnly, true); + assert.equal(workspaceById("polygon-run").root, "polygon"); assert.equal( - workspacesForRoot("system").some(({ id }) => id === "polygon-run-internal"), + workspacesForRoot("polygon").some(({ id }) => id === "polygon-run"), + true, + ); + assert.equal( + workspacesForRoot("system").some(({ id }) => id === "polygon-run"), false, ); + assert.equal( + rootsForCapabilities({ polygonWorkerAvailable: false }) + .some(({ id }) => id === "polygon"), + false, + ); + assert.equal( + rootsForCapabilities({ polygonWorkerAvailable: true }) + .some(({ id }) => id === "polygon"), + true, + ); }); test("Polygon contracts decode path-free evidence and preserve the safety boundary", () => {