feat(simulation): add worker-gated 3d polygon workspace
This commit is contained in:
parent
1995dfdf68
commit
6cc40282e0
|
|
@ -15,12 +15,14 @@
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
"@rerun-io/web-viewer": "0.34.1",
|
"@rerun-io/web-viewer": "0.34.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0",
|
||||||
|
"three": "0.185.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
|
"@types/three": "0.185.1",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
|
|
@ -344,6 +346,13 @@
|
||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
|
|
@ -1223,6 +1232,13 @@
|
||||||
"win32"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|
@ -1305,6 +1321,35 @@
|
||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
|
@ -1571,6 +1623,13 @@
|
||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -1758,6 +1817,12 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,14 @@
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
"@rerun-io/web-viewer": "0.34.1",
|
"@rerun-io/web-viewer": "0.34.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0",
|
||||||
|
"three": "0.185.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
|
"@types/three": "0.185.1",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import type {
|
||||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||||
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
|
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
|
||||||
|
import { usePolygonWorkerCapability } from "./core/polygon/usePolygonWorkerCapability";
|
||||||
import {
|
import {
|
||||||
OBSERVATION_WORKSPACE_ID,
|
OBSERVATION_WORKSPACE_ID,
|
||||||
OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||||
|
|
@ -54,7 +55,7 @@ import {
|
||||||
} from "./core/observation/workspaceLayout";
|
} from "./core/observation/workspaceLayout";
|
||||||
import {
|
import {
|
||||||
rootById,
|
rootById,
|
||||||
roots,
|
rootsForCapabilities,
|
||||||
workspaceById,
|
workspaceById,
|
||||||
workspacesForRoot,
|
workspacesForRoot,
|
||||||
type RootId,
|
type RootId,
|
||||||
|
|
@ -150,6 +151,7 @@ function mergeViewerSettings(
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const runtime = useMissionRuntime();
|
const runtime = useMissionRuntime();
|
||||||
const { selection } = useDevicePluginHost();
|
const { selection } = useDevicePluginHost();
|
||||||
|
const polygonWorker = usePolygonWorkerCapability();
|
||||||
const polygonRunRoute = useMemo(
|
const polygonRunRoute = useMemo(
|
||||||
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
|
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
|
||||||
[],
|
[],
|
||||||
|
|
@ -160,7 +162,7 @@ export default function App() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
||||||
polygonRunRoute.active ? "system" : null,
|
polygonRunRoute.active ? "polygon" : null,
|
||||||
);
|
);
|
||||||
const [sourceUrl, setSourceUrl] = useState("");
|
const [sourceUrl, setSourceUrl] = useState("");
|
||||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||||
|
|
@ -197,6 +199,10 @@ export default function App() {
|
||||||
const polygonRunRouteOpenedRef = useRef(false);
|
const polygonRunRouteOpenedRef = useRef(false);
|
||||||
|
|
||||||
const currentRoot = rootById(activeRoot);
|
const currentRoot = rootById(activeRoot);
|
||||||
|
const visibleRoots = useMemo(
|
||||||
|
() => rootsForCapabilities({ polygonWorkerAvailable: polygonWorker.available }),
|
||||||
|
[polygonWorker.available],
|
||||||
|
);
|
||||||
const activeDefinition = workspaceById(workspace.activeView);
|
const activeDefinition = workspaceById(workspace.activeView);
|
||||||
const spatialWorkspaceActive = Boolean(
|
const spatialWorkspaceActive = Boolean(
|
||||||
workspace.contentOpen && activeDefinition?.kind === "spatial",
|
workspace.contentOpen && activeDefinition?.kind === "spatial",
|
||||||
|
|
@ -215,7 +221,7 @@ export default function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!polygonRunRoute.active || polygonRunRouteOpenedRef.current) return;
|
if (!polygonRunRoute.active || polygonRunRouteOpenedRef.current) return;
|
||||||
polygonRunRouteOpenedRef.current = true;
|
polygonRunRouteOpenedRef.current = true;
|
||||||
workspace.openView("polygon-run-internal");
|
workspace.openView("polygon-run");
|
||||||
}, [polygonRunRoute.active, workspace]);
|
}, [polygonRunRoute.active, workspace]);
|
||||||
|
|
||||||
if (!sceneSettingsCommitterRef.current) {
|
if (!sceneSettingsCommitterRef.current) {
|
||||||
|
|
@ -413,6 +419,11 @@ export default function App() {
|
||||||
|
|
||||||
const selectRoot = (rootId: RootId) => {
|
const selectRoot = (rootId: RootId) => {
|
||||||
setActiveRoot(rootId);
|
setActiveRoot(rootId);
|
||||||
|
if (rootId === "polygon") {
|
||||||
|
workspace.closeNavigation();
|
||||||
|
workspace.openView("polygon-run");
|
||||||
|
return;
|
||||||
|
}
|
||||||
workspace.closeView();
|
workspace.closeView();
|
||||||
workspace.openNavigation();
|
workspace.openNavigation();
|
||||||
};
|
};
|
||||||
|
|
@ -618,7 +629,7 @@ export default function App() {
|
||||||
<HeaderNavigation
|
<HeaderNavigation
|
||||||
label="Архитектурные блоки пункта управления"
|
label="Архитектурные блоки пункта управления"
|
||||||
value={activeRoot ?? undefined}
|
value={activeRoot ?? undefined}
|
||||||
items={roots.map((root) => ({ value: root.id, label: root.label }))}
|
items={visibleRoots.map((root) => ({ value: root.id, label: root.label }))}
|
||||||
onChange={selectRoot}
|
onChange={selectRoot}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
@ -733,7 +744,7 @@ export default function App() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : activeDefinition.kind === "polygon-run" ? (
|
) : activeDefinition.kind === "polygon-run" ? (
|
||||||
<StatusBadge tone="accent">Только чтение</StatusBadge>
|
<StatusBadge tone="accent">Virtual only</StatusBadge>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import type { IconName } from "@nodedc/ui-react";
|
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 =
|
export type WorkspaceKind =
|
||||||
| "overview"
|
| "overview"
|
||||||
|
|
@ -120,6 +127,15 @@ export const roots: RootDefinition[] = [
|
||||||
statement: "Хранить живой контур и воспроизводимый эксперимент как одну модель данных.",
|
statement: "Хранить живой контур и воспроизводимый эксперимент как одну модель данных.",
|
||||||
accent: "ПОТОКИ И ДОКАЗАТЕЛЬСТВА",
|
accent: "ПОТОКИ И ДОКАЗАТЕЛЬСТВА",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "polygon",
|
||||||
|
label: "Полигон",
|
||||||
|
title: "Полигон",
|
||||||
|
eyebrow: "СИМУЛЯЦИЯ И КВАЛИФИКАЦИЯ",
|
||||||
|
description: "Живые PX4/Gazebo прогоны, сценарии, траектории и доказательства.",
|
||||||
|
statement: "Проверять поведение машины в воспроизводимой виртуальной среде.",
|
||||||
|
accent: "СИМУЛЯЦИЯ И ДОКАЗАТЕЛЬСТВА",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "system",
|
id: "system",
|
||||||
label: "Система",
|
label: "Система",
|
||||||
|
|
@ -133,15 +149,14 @@ export const roots: RootDefinition[] = [
|
||||||
|
|
||||||
export const workspaces: WorkspaceDefinition[] = [
|
export const workspaces: WorkspaceDefinition[] = [
|
||||||
{
|
{
|
||||||
id: "polygon-run-internal",
|
id: "polygon-run",
|
||||||
root: "system",
|
root: "polygon",
|
||||||
label: "Полигон",
|
label: "Полигон",
|
||||||
title: "Полигон",
|
title: "Полигон",
|
||||||
eyebrow: "ПОЛИГОН / UI-1",
|
eyebrow: "ПОЛИГОН / LIVE",
|
||||||
description: "Live Simulation Worker и доказательства прогонов PX4/Gazebo.",
|
description: "Live Simulation Worker и доказательства прогонов PX4/Gazebo.",
|
||||||
icon: "activity",
|
icon: "activity",
|
||||||
kind: "polygon-run",
|
kind: "polygon-run",
|
||||||
internalOnly: true,
|
|
||||||
groups: [],
|
groups: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -756,6 +771,14 @@ export function rootById(id: RootId | null): RootDefinition | null {
|
||||||
return id ? roots.find((root) => root.id === id) ?? null : 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 {
|
export function workspaceById(id: string | null): WorkspaceDefinition | null {
|
||||||
return id ? workspaces.find((workspace) => workspace.id === id) ?? null : null;
|
return id ? workspaces.find((workspace) => workspace.id === id) ?? null : null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,8 +166,13 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.polygon-live-map {
|
.polygon-rover-scene {
|
||||||
min-height: 15rem;
|
min-height: 19rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polygon-rover-scene__frames small,
|
||||||
|
.polygon-rover-scene__help {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.polygon-run-event-list article {
|
.polygon-run-event-list article {
|
||||||
|
|
|
||||||
|
|
@ -138,84 +138,160 @@
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.polygon-live-map {
|
.polygon-rover-scene {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 19rem;
|
min-height: 25rem;
|
||||||
border: 1px solid var(--station-hairline);
|
border: 1px solid var(--station-hairline);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 48% 46%, rgb(var(--nodedc-accent-rgb) / 0.055), transparent 45%),
|
radial-gradient(circle at 62% 28%, rgb(var(--nodedc-accent-rgb) / 0.13), transparent 42%),
|
||||||
rgb(255 255 255 / 0.018);
|
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;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.polygon-live-map pattern path {
|
.polygon-rover-scene__viewport canvas {
|
||||||
fill: none;
|
cursor: grab;
|
||||||
stroke: rgb(255 255 255 / 0.045);
|
touch-action: none;
|
||||||
stroke-width: 0.25;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.polygon-live-map text {
|
.polygon-rover-scene__viewport canvas:active {
|
||||||
fill: var(--nodedc-text-muted);
|
cursor: grabbing;
|
||||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.polygon-live-axis-x,
|
.polygon-rover-scene__toolbar,
|
||||||
.polygon-live-axis-y {
|
.polygon-rover-scene__help,
|
||||||
stroke: rgb(255 255 255 / 0.28);
|
.polygon-rover-scene__frames,
|
||||||
stroke-width: 0.45;
|
.polygon-rover-scene__idle {
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
z-index: 2;
|
||||||
left: 50%;
|
|
||||||
display: grid;
|
|
||||||
width: min(22rem, 76%);
|
|
||||||
gap: 0.35rem;
|
|
||||||
text-align: center;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
color: var(--nodedc-text-primary);
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.polygon-live-map > div span,
|
.polygon-rover-scene__help {
|
||||||
.polygon-live-map > small {
|
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);
|
color: var(--nodedc-text-muted);
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.polygon-live-map > small {
|
.polygon-rover-scene__frames {
|
||||||
position: absolute;
|
right: 0.75rem;
|
||||||
right: 0.8rem;
|
|
||||||
bottom: 0.7rem;
|
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 {
|
.polygon-live-telemetry {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
GlassSurface,
|
GlassSurface,
|
||||||
|
|
@ -14,44 +14,16 @@ import {
|
||||||
type PolygonWorkerStatus,
|
type PolygonWorkerStatus,
|
||||||
} from "../core/polygon/liveWorker";
|
} from "../core/polygon/liveWorker";
|
||||||
|
|
||||||
|
const PolygonRoverScene = lazy(async () => {
|
||||||
|
const module = await import("./PolygonRoverScene");
|
||||||
|
return { default: module.PolygonRoverScene };
|
||||||
|
});
|
||||||
|
|
||||||
function message(error: unknown): string {
|
function message(error: unknown): string {
|
||||||
if (error instanceof Error && error.message.trim()) return error.message;
|
if (error instanceof Error && error.message.trim()) return error.message;
|
||||||
return "Simulation Worker не подтвердил операцию.";
|
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() {
|
export function PolygonLivePanel() {
|
||||||
const [worker, setWorker] = useState<PolygonWorkerStatus | null>(null);
|
const [worker, setWorker] = useState<PolygonWorkerStatus | null>(null);
|
||||||
const [live, setLive] = useState<PolygonVehicleState | null>(null);
|
const [live, setLive] = useState<PolygonVehicleState | null>(null);
|
||||||
|
|
@ -99,7 +71,6 @@ export function PolygonLivePanel() {
|
||||||
};
|
};
|
||||||
}, [actionBusy, generation]);
|
}, [actionBusy, generation]);
|
||||||
|
|
||||||
const projected = useMemo(() => trajectoryPoints(trajectory), [trajectory]);
|
|
||||||
const runActive = Boolean(worker?.activeRunId);
|
const runActive = Boolean(worker?.activeRunId);
|
||||||
|
|
||||||
const runAction = async () => {
|
const runAction = async () => {
|
||||||
|
|
@ -150,44 +121,19 @@ export function PolygonLivePanel() {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="polygon-live-layout">
|
<div className="polygon-live-layout">
|
||||||
<div className="polygon-live-map" aria-label="Live-траектория ровера в ENU">
|
<Suspense
|
||||||
<svg viewBox="0 0 100 100" role="img">
|
fallback={
|
||||||
<defs>
|
<div className="polygon-rover-scene polygon-rover-scene--loading">
|
||||||
<pattern id="polygon-grid" width="10" height="10" patternUnits="userSpaceOnUse">
|
Загружаем 3D-сцену…
|
||||||
<path d="M 10 0 L 0 0 0 10" />
|
|
||||||
</pattern>
|
|
||||||
<radialGradient id="polygon-rover-glow">
|
|
||||||
<stop offset="0" stopColor="rgb(var(--nodedc-accent-rgb))" stopOpacity="0.9" />
|
|
||||||
<stop offset="1" stopColor="rgb(var(--nodedc-accent-rgb))" stopOpacity="0" />
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="100" height="100" fill="url(#polygon-grid)" />
|
|
||||||
<line x1="8" y1="92" x2="25" y2="92" className="polygon-live-axis-x" />
|
|
||||||
<line x1="8" y1="92" x2="8" y2="75" className="polygon-live-axis-y" />
|
|
||||||
<text x="27" y="94">E</text>
|
|
||||||
<text x="5" y="72">N</text>
|
|
||||||
{projected.points && (
|
|
||||||
<polyline points={projected.points} className="polygon-live-trajectory" />
|
|
||||||
)}
|
|
||||||
{live && (
|
|
||||||
<g
|
|
||||||
transform={
|
|
||||||
`translate(${projected.roverX} ${projected.roverY}) rotate(${-yawDegrees(live)})`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<circle r="7" fill="url(#polygon-rover-glow)" />
|
|
||||||
<path d="M -3 -2.5 L 4 0 L -3 2.5 Z" className="polygon-live-rover" />
|
|
||||||
</g>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
{!live && (
|
|
||||||
<div>
|
|
||||||
<strong>{worker?.available ? "Ровер не запущен" : "Нет связи с worker"}</strong>
|
|
||||||
<span>После старта здесь появится ground-truth траектория из Gazebo.</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
<small>map_enu · base_link_flu · diagnostic ground truth</small>
|
>
|
||||||
</div>
|
<PolygonRoverScene
|
||||||
|
live={live}
|
||||||
|
trajectory={trajectory}
|
||||||
|
workerAvailable={Boolean(worker?.available)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<div className="polygon-live-telemetry">
|
<div className="polygon-live-telemetry">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -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<HTMLDivElement | null>(null);
|
||||||
|
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||||
|
const controlsRef = useRef<OrbitControls | null>(null);
|
||||||
|
const roverRef = useRef<THREE.Group | null>(null);
|
||||||
|
const trajectoryRef = useRef<THREE.Line<THREE.BufferGeometry, THREE.LineBasicMaterial> | 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<string | null>(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 (
|
||||||
|
<div className="polygon-rover-scene" aria-label="Live 3D-полигон">
|
||||||
|
<div ref={hostRef} className="polygon-rover-scene__viewport">
|
||||||
|
{renderError ? <p className="polygon-rover-scene__error">{renderError}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="polygon-rover-scene__toolbar">
|
||||||
|
<button type="button" onClick={() => setFollowRover((value) => !value)}>
|
||||||
|
{followRover ? "Следовать за ровером" : "Свободная камера"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={resetCamera}>Сбросить ракурс</button>
|
||||||
|
</div>
|
||||||
|
<div className="polygon-rover-scene__help">
|
||||||
|
<span>ЛКМ · вращение</span>
|
||||||
|
<span>Колесо · масштаб</span>
|
||||||
|
<span>ПКМ · панорама</span>
|
||||||
|
</div>
|
||||||
|
<div className="polygon-rover-scene__frames">
|
||||||
|
<span><i data-axis="east" />E</span>
|
||||||
|
<span><i data-axis="north" />N</span>
|
||||||
|
<span><i data-axis="up" />U</span>
|
||||||
|
<small>map_enu · base_link_flu · ground truth</small>
|
||||||
|
</div>
|
||||||
|
{!live ? (
|
||||||
|
<div className="polygon-rover-scene__idle">
|
||||||
|
<strong>{workerAvailable ? "Ackermann Rover готов" : "Нет связи с worker"}</strong>
|
||||||
|
<span>
|
||||||
|
{workerAvailable
|
||||||
|
? "Запустите PX4/Gazebo — модель останется доступна для осмотра."
|
||||||
|
: "Полигон появится в шапке после подтверждения Simulation Worker."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ let stopPolygonWorker;
|
||||||
let PolygonWorkerContractError;
|
let PolygonWorkerContractError;
|
||||||
let workspaceById;
|
let workspaceById;
|
||||||
let workspacesForRoot;
|
let workspacesForRoot;
|
||||||
|
let rootsForCapabilities;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
|
|
@ -43,7 +44,11 @@ before(async () => {
|
||||||
stopPolygonWorker,
|
stopPolygonWorker,
|
||||||
PolygonWorkerContractError,
|
PolygonWorkerContractError,
|
||||||
} = await server.ssrLoadModule("/src/core/polygon/liveWorker.ts"));
|
} = 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 () => {
|
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"), {
|
assert.deepEqual(resolvePolygonRunRoute("?workspace=polygon-run"), {
|
||||||
active: true,
|
active: true,
|
||||||
runId: null,
|
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,
|
resolvePolygonRunRoute("?workspace=polygon-run&run=../../etc").error,
|
||||||
/некорректный/,
|
/некорректный/,
|
||||||
);
|
);
|
||||||
assert.equal(workspaceById("polygon-run-internal").internalOnly, true);
|
assert.equal(workspaceById("polygon-run").root, "polygon");
|
||||||
assert.equal(
|
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,
|
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", () => {
|
test("Polygon contracts decode path-free evidence and preserve the safety boundary", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue