feat(simulation): add worker-gated 3d polygon workspace
This commit is contained in:
@@ -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<PolygonWorkerStatus | null>(null);
|
||||
const [live, setLive] = useState<PolygonVehicleState | null>(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() {
|
||||
</header>
|
||||
|
||||
<div className="polygon-live-layout">
|
||||
<div className="polygon-live-map" aria-label="Live-траектория ровера в ENU">
|
||||
<svg viewBox="0 0 100 100" role="img">
|
||||
<defs>
|
||||
<pattern id="polygon-grid" width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<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>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="polygon-rover-scene polygon-rover-scene--loading">
|
||||
Загружаем 3D-сцену…
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user