perf(lab): retain spatial scenes during replay
This commit is contained in:
+233
-135
@@ -169,6 +169,47 @@ function positions(points: readonly LaboratoryMetricPoint3[]): Float32Array {
|
||||
return result;
|
||||
}
|
||||
|
||||
function updatePointPositions(
|
||||
geometry: THREE.BufferGeometry,
|
||||
points: readonly LaboratoryMetricPoint3[],
|
||||
): void {
|
||||
const requiredValues = points.length * 3;
|
||||
let attribute = geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
|
||||
if (!attribute || attribute.array.length < requiredValues) {
|
||||
let capacity = 3;
|
||||
while (capacity < requiredValues) capacity *= 2;
|
||||
attribute = new THREE.BufferAttribute(new Float32Array(capacity), 3);
|
||||
attribute.setUsage(THREE.DynamicDrawUsage);
|
||||
geometry.setAttribute("position", attribute);
|
||||
}
|
||||
const values = attribute.array as Float32Array;
|
||||
points.forEach((point, index) => {
|
||||
const [x, y, z] = scenePoint(point);
|
||||
const offset = index * 3;
|
||||
values[offset] = x;
|
||||
values[offset + 1] = y;
|
||||
values[offset + 2] = z;
|
||||
});
|
||||
attribute.needsUpdate = true;
|
||||
geometry.setDrawRange(0, points.length);
|
||||
}
|
||||
|
||||
function ensurePointColors(
|
||||
geometry: THREE.BufferGeometry,
|
||||
pointCount: number,
|
||||
): THREE.BufferAttribute {
|
||||
const requiredValues = pointCount * 3;
|
||||
let attribute = geometry.getAttribute("color") as THREE.BufferAttribute | undefined;
|
||||
if (!attribute || attribute.array.length < requiredValues) {
|
||||
let capacity = 3;
|
||||
while (capacity < requiredValues) capacity *= 2;
|
||||
attribute = new THREE.BufferAttribute(new Float32Array(capacity), 3);
|
||||
attribute.setUsage(THREE.DynamicDrawUsage);
|
||||
geometry.setAttribute("color", attribute);
|
||||
}
|
||||
return attribute;
|
||||
}
|
||||
|
||||
function decisionColor(
|
||||
host: HTMLElement,
|
||||
decision: LaboratoryMetricDecision,
|
||||
@@ -244,6 +285,12 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const staticContentRef = useRef<THREE.Group | null>(null);
|
||||
const dynamicContentRef = useRef<THREE.Group | null>(null);
|
||||
const classifiedContentRef = useRef<THREE.Group | null>(null);
|
||||
const localSurfacePointsRef = useRef<THREE.Points | null>(null);
|
||||
const currentIncrementPointsRef = useRef<THREE.Points | null>(null);
|
||||
const classifiedMeshesRef = useRef<ReadonlyMap<LaboratoryMetricCellState, THREE.InstancedMesh>>(
|
||||
new Map(),
|
||||
);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -278,12 +325,48 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
controls.maxDistance = 80;
|
||||
const staticContent = new THREE.Group();
|
||||
const dynamicContent = new THREE.Group();
|
||||
scene.add(staticContent, dynamicContent);
|
||||
const classifiedContent = new THREE.Group();
|
||||
const localSurfacePoints = new THREE.Points(
|
||||
new THREE.BufferGeometry(),
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
size: 1.3,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
const currentIncrementPoints = new THREE.Points(
|
||||
new THREE.BufferGeometry(),
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
size: 1.55,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.58,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
localSurfacePoints.visible = false;
|
||||
localSurfacePoints.frustumCulled = false;
|
||||
currentIncrementPoints.visible = false;
|
||||
currentIncrementPoints.frustumCulled = false;
|
||||
scene.add(
|
||||
staticContent,
|
||||
localSurfacePoints,
|
||||
currentIncrementPoints,
|
||||
dynamicContent,
|
||||
classifiedContent,
|
||||
);
|
||||
sceneRef.current = scene;
|
||||
cameraRef.current = camera;
|
||||
controlsRef.current = controls;
|
||||
staticContentRef.current = staticContent;
|
||||
dynamicContentRef.current = dynamicContent;
|
||||
classifiedContentRef.current = classifiedContent;
|
||||
localSurfacePointsRef.current = localSurfacePoints;
|
||||
currentIncrementPointsRef.current = currentIncrementPoints;
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
@@ -315,6 +398,10 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
controlsRef.current = null;
|
||||
staticContentRef.current = null;
|
||||
dynamicContentRef.current = null;
|
||||
classifiedContentRef.current = null;
|
||||
localSurfacePointsRef.current = null;
|
||||
currentIncrementPointsRef.current = null;
|
||||
classifiedMeshesRef.current = new Map();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -323,137 +410,71 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
if (canvas) canvas.setAttribute("aria-label", label);
|
||||
}, [label]);
|
||||
|
||||
useEffect(() => {
|
||||
const points = localSurfacePointsRef.current;
|
||||
if (!points) return;
|
||||
points.visible = showLocalSurface && localSurfaceBodyXyzM.length > 0;
|
||||
if (points.visible) updatePointPositions(points.geometry, localSurfaceBodyXyzM);
|
||||
}, [localSurfaceBodyXyzM, showLocalSurface]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const points = currentIncrementPointsRef.current;
|
||||
if (!host || !points) return;
|
||||
points.visible = showCurrentIncrement && pointCloudBodyXyzM.length > 0;
|
||||
if (!points.visible) return;
|
||||
updatePointPositions(points.geometry, pointCloudBodyXyzM);
|
||||
const material = points.material as THREE.PointsMaterial;
|
||||
const hasAlignedSemanticClasses =
|
||||
pointSemanticClassIds !== undefined
|
||||
&& pointSemanticClassIds.length === pointCloudBodyXyzM.length
|
||||
&& semanticClasses !== undefined
|
||||
&& semanticPalette !== undefined;
|
||||
if (!hasAlignedSemanticClasses) {
|
||||
if (material.vertexColors) {
|
||||
material.vertexColors = false;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
material.color.copy(tokenColor(host, "--nodedc-text-muted", [147, 151, 159]));
|
||||
return;
|
||||
}
|
||||
const declaredIds = new Set(semanticClasses.map((item) => item.id));
|
||||
const colorsByClassId = new Map<number, readonly [number, number, number]>();
|
||||
for (const entry of semanticPalette) {
|
||||
if (!declaredIds.has(entry.classId)) continue;
|
||||
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||
if (rgb) colorsByClassId.set(entry.classId, rgb);
|
||||
}
|
||||
const context = tokenColor(host, "--nodedc-text-muted", [147, 151, 159]);
|
||||
const colorAttribute = ensurePointColors(points.geometry, pointCloudBodyXyzM.length);
|
||||
const pointColors = colorAttribute.array as Float32Array;
|
||||
pointSemanticClassIds.forEach((classId, index) => {
|
||||
const rgb = classId === null ? undefined : colorsByClassId.get(classId);
|
||||
const offset = index * 3;
|
||||
pointColors[offset] = rgb ? rgb[0] / 255 : context.r;
|
||||
pointColors[offset + 1] = rgb ? rgb[1] / 255 : context.g;
|
||||
pointColors[offset + 2] = rgb ? rgb[2] / 255 : context.b;
|
||||
});
|
||||
colorAttribute.needsUpdate = true;
|
||||
if (!material.vertexColors) {
|
||||
material.vertexColors = true;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
material.color.setRGB(1, 1, 1);
|
||||
}, [
|
||||
pointCloudBodyXyzM,
|
||||
pointSemanticClassIds,
|
||||
semanticClasses,
|
||||
semanticPalette,
|
||||
showCurrentIncrement,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = dynamicContentRef.current;
|
||||
if (!host || !content) return;
|
||||
clearGroup(content);
|
||||
|
||||
if (showLocalSurface) {
|
||||
const localSurfaceGeometry = new THREE.BufferGeometry();
|
||||
localSurfaceGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(localSurfaceBodyXyzM), 3),
|
||||
);
|
||||
content.add(new THREE.Points(
|
||||
localSurfaceGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
size: 1.3,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if (showCurrentIncrement) {
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
contextGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3),
|
||||
);
|
||||
const hasAlignedSemanticClasses =
|
||||
pointSemanticClassIds !== undefined
|
||||
&& pointSemanticClassIds.length === pointCloudBodyXyzM.length
|
||||
&& semanticClasses !== undefined
|
||||
&& semanticPalette !== undefined;
|
||||
if (hasAlignedSemanticClasses) {
|
||||
const declaredIds = new Set(semanticClasses.map((item) => item.id));
|
||||
const colorsByClassId = new Map<number, readonly [number, number, number]>();
|
||||
for (const entry of semanticPalette) {
|
||||
if (!declaredIds.has(entry.classId)) continue;
|
||||
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||
if (rgb) colorsByClassId.set(entry.classId, rgb);
|
||||
}
|
||||
const context = tokenColor(host, "--nodedc-text-muted", [147, 151, 159]);
|
||||
const pointColors = new Float32Array(pointCloudBodyXyzM.length * 3);
|
||||
pointSemanticClassIds.forEach((classId, index) => {
|
||||
const rgb = classId === null ? undefined : colorsByClassId.get(classId);
|
||||
const offset = index * 3;
|
||||
pointColors[offset] = rgb ? rgb[0] / 255 : context.r;
|
||||
pointColors[offset + 1] = rgb ? rgb[1] / 255 : context.g;
|
||||
pointColors[offset + 2] = rgb ? rgb[2] / 255 : context.b;
|
||||
});
|
||||
contextGeometry.setAttribute(
|
||||
"color",
|
||||
new THREE.BufferAttribute(pointColors, 3),
|
||||
);
|
||||
}
|
||||
content.add(new THREE.Points(
|
||||
contextGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: hasAlignedSemanticClasses
|
||||
? new THREE.Color(1, 1, 1)
|
||||
: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
vertexColors: hasAlignedSemanticClasses,
|
||||
size: 1.55,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.58,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if (showClassifiedCells && classifiedCells.length) {
|
||||
const cellsByState = new Map<LaboratoryMetricCellState, LaboratoryMetricCellEvidence[]>();
|
||||
for (const cell of classifiedCells) {
|
||||
const cells = cellsByState.get(cell.state) ?? [];
|
||||
cells.push(cell);
|
||||
cellsByState.set(cell.state, cells);
|
||||
}
|
||||
for (const [state, cells] of cellsByState) {
|
||||
const color = state === "ground-support"
|
||||
? tokenColor(host, "--nodedc-success-rgb", [181, 255, 90])
|
||||
: state === "nonground-occupied"
|
||||
? tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112])
|
||||
: state === "unknown-rejected"
|
||||
? tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92])
|
||||
: tokenColor(host, "--nodedc-text-muted", [96, 99, 106]);
|
||||
const geometry = new THREE.BoxGeometry(
|
||||
classifiedCellSizeM * 0.92,
|
||||
1,
|
||||
classifiedCellSizeM * 0.92,
|
||||
);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: state === "unobserved" ? 0.035 : state === "ground-support" ? 0.12 : 0.24,
|
||||
depthWrite: false,
|
||||
});
|
||||
const mesh = new THREE.InstancedMesh(geometry, material, cells.length);
|
||||
const matrix = new THREE.Matrix4();
|
||||
const scale = new THREE.Vector3(1, 1, 1);
|
||||
const rotation = new THREE.Quaternion();
|
||||
cells.forEach((cell, index) => {
|
||||
const minimum = cell.zBoundsM[0];
|
||||
const maximum = cell.zBoundsM[1];
|
||||
const height = minimum === null || maximum === null
|
||||
? 0.018
|
||||
: Math.max(0.018, maximum - minimum);
|
||||
const centerZ = minimum === null || maximum === null
|
||||
? -0.012
|
||||
: (minimum + maximum) / 2;
|
||||
const [sceneX, sceneY, sceneZ] = scenePoint([
|
||||
cell.centerBodyXyM[0],
|
||||
cell.centerBodyXyM[1],
|
||||
centerZ,
|
||||
]);
|
||||
scale.set(1, height, 1);
|
||||
matrix.compose(
|
||||
new THREE.Vector3(sceneX, sceneY, sceneZ),
|
||||
rotation,
|
||||
scale,
|
||||
);
|
||||
mesh.setMatrixAt(index, matrix);
|
||||
});
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
content.add(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
for (const obstacle of obstacles) {
|
||||
if (
|
||||
(
|
||||
@@ -526,20 +547,97 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
}, [
|
||||
obstacles,
|
||||
occupiedVoxelSizeM,
|
||||
localSurfaceBodyXyzM,
|
||||
pointCloudBodyXyzM,
|
||||
pointSemanticClassIds,
|
||||
semanticClasses,
|
||||
semanticPalette,
|
||||
classifiedCells,
|
||||
classifiedCellSizeM,
|
||||
showCurrentIncrement,
|
||||
showClassifiedCells,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
showLowStep,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = classifiedContentRef.current;
|
||||
if (!host || !content) return;
|
||||
content.visible = showClassifiedCells && classifiedCells.length > 0;
|
||||
if (!content.visible) return;
|
||||
|
||||
const requiredCapacity = classifiedCells.length;
|
||||
const currentMeshes = classifiedMeshesRef.current;
|
||||
const firstMesh = currentMeshes.values().next().value as THREE.InstancedMesh | undefined;
|
||||
const needsAllocation = !firstMesh
|
||||
|| Number(firstMesh.userData.capacity ?? 0) < requiredCapacity
|
||||
|| Number(firstMesh.userData.cellSizeM ?? 0) !== classifiedCellSizeM;
|
||||
if (needsAllocation) {
|
||||
clearGroup(content);
|
||||
const meshes = new Map<LaboratoryMetricCellState, THREE.InstancedMesh>();
|
||||
const states: readonly LaboratoryMetricCellState[] = [
|
||||
"unobserved",
|
||||
"ground-support",
|
||||
"nonground-occupied",
|
||||
"unknown-rejected",
|
||||
];
|
||||
for (const state of states) {
|
||||
const color = state === "ground-support"
|
||||
? tokenColor(host, "--nodedc-success-rgb", [181, 255, 90])
|
||||
: state === "nonground-occupied"
|
||||
? tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112])
|
||||
: state === "unknown-rejected"
|
||||
? tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92])
|
||||
: tokenColor(host, "--nodedc-text-muted", [96, 99, 106]);
|
||||
const mesh = new THREE.InstancedMesh(
|
||||
new THREE.BoxGeometry(classifiedCellSizeM * 0.92, 1, classifiedCellSizeM * 0.92),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: state === "unobserved" ? 0.035 : state === "ground-support" ? 0.12 : 0.24,
|
||||
depthWrite: false,
|
||||
}),
|
||||
requiredCapacity,
|
||||
);
|
||||
mesh.count = 0;
|
||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
mesh.frustumCulled = false;
|
||||
mesh.userData.capacity = requiredCapacity;
|
||||
mesh.userData.cellSizeM = classifiedCellSizeM;
|
||||
meshes.set(state, mesh);
|
||||
content.add(mesh);
|
||||
}
|
||||
classifiedMeshesRef.current = meshes;
|
||||
}
|
||||
|
||||
const meshes = classifiedMeshesRef.current;
|
||||
const counts = new Map<LaboratoryMetricCellState, number>();
|
||||
const matrix = new THREE.Matrix4();
|
||||
const position = new THREE.Vector3();
|
||||
const scale = new THREE.Vector3(1, 1, 1);
|
||||
const rotation = new THREE.Quaternion();
|
||||
for (const cell of classifiedCells) {
|
||||
const mesh = meshes.get(cell.state);
|
||||
if (!mesh) continue;
|
||||
const index = counts.get(cell.state) ?? 0;
|
||||
const minimum = cell.zBoundsM[0];
|
||||
const maximum = cell.zBoundsM[1];
|
||||
const height = minimum === null || maximum === null
|
||||
? 0.018
|
||||
: Math.max(0.018, maximum - minimum);
|
||||
const centerZ = minimum === null || maximum === null
|
||||
? -0.012
|
||||
: (minimum + maximum) / 2;
|
||||
const [sceneX, sceneY, sceneZ] = scenePoint([
|
||||
cell.centerBodyXyM[0],
|
||||
cell.centerBodyXyM[1],
|
||||
centerZ,
|
||||
]);
|
||||
position.set(sceneX, sceneY, sceneZ);
|
||||
scale.set(1, height, 1);
|
||||
matrix.compose(position, rotation, scale);
|
||||
mesh.setMatrixAt(index, matrix);
|
||||
counts.set(cell.state, index + 1);
|
||||
}
|
||||
for (const [state, mesh] of meshes) {
|
||||
mesh.count = counts.get(state) ?? 0;
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
}, [classifiedCellSizeM, classifiedCells, showClassifiedCells]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = staticContentRef.current;
|
||||
|
||||
+8
-3
@@ -510,16 +510,21 @@ export function RecordedEvidenceSemanticMaskOverlay({
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const rendererRef = useRef<SemanticMaskRenderer | null | undefined>(undefined);
|
||||
const lastReadyMaskRef = useRef<{ series: string; mask: DecodedSemanticMask } | null>(null);
|
||||
const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl");
|
||||
const [mask, setMask] = useState<DecodedSemanticMask | null>(null);
|
||||
const [failure, setFailure] = useState<string | null>(null);
|
||||
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
|
||||
const prefetchSignature = prefetchSrcs.join("\n");
|
||||
const renderMask = failure
|
||||
const series = src.slice(0, Math.max(src.lastIndexOf("/"), 0));
|
||||
if (lastReadyMaskRef.current?.series !== series) lastReadyMaskRef.current = null;
|
||||
const exactMask = failure
|
||||
? null
|
||||
: mask?.key === expectedKey
|
||||
? mask
|
||||
: decodedMaskCache.get(expectedKey) ?? null;
|
||||
if (exactMask) lastReadyMaskRef.current = { series, mask: exactMask };
|
||||
const renderMask = exactMask ?? lastReadyMaskRef.current?.mask ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setFailure(null);
|
||||
@@ -627,8 +632,8 @@ export function RecordedEvidenceSemanticMaskOverlay({
|
||||
className="recorded-evidence-semantic-mask-overlay"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={!failure && !renderMask}
|
||||
data-state={failure ? "error" : renderMask ? "ready" : "loading"}
|
||||
aria-busy={!failure && !exactMask}
|
||||
data-state={failure ? "error" : exactMask ? "ready" : renderMask ? "stale" : "loading"}
|
||||
data-renderer={rendererMode}
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
RecordedEvidenceSemanticClass,
|
||||
@@ -9,10 +9,11 @@ import {
|
||||
type E47SemanticSlamResult,
|
||||
} from "../../core/laboratory/e47SemanticSlam";
|
||||
import {
|
||||
fetchM49TgsFullShadowSpatialChunk,
|
||||
fetchM49TgsFullShadowPlaybackPack,
|
||||
type M49TgsFullShadowResult,
|
||||
type M49TgsFullShadowPlaybackPack,
|
||||
type M49TgsFullShadowPlaybackProgress,
|
||||
type M49TgsFullShadowSpatial,
|
||||
type M49TgsFullShadowSpatialChunk,
|
||||
type M49TgsFullShadowStateCode,
|
||||
} from "../../core/laboratory/m49TgsFullShadow";
|
||||
import {
|
||||
@@ -32,9 +33,6 @@ const PALETTE: readonly RecordedEvidenceSemanticPaletteEntry[] = [
|
||||
{ classId: 3, color: { kind: "token", token: "--nodedc-warning-rgb" } },
|
||||
];
|
||||
|
||||
const CHUNK_FRAMES = 24;
|
||||
const RETAINED_CHUNKS = 4;
|
||||
|
||||
function cellState(code: M49TgsFullShadowStateCode): M4ReplayClassifiedSpatialFrame["cellsMapGravityLocal"][number]["state"] {
|
||||
if (code === 1) return "ground-support";
|
||||
if (code === 2) return "nonground-occupied";
|
||||
@@ -52,16 +50,14 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
|
||||
const [activeSequence, setActiveSequence] = useState<number | null>(null);
|
||||
const [semantic, setSemantic] = useState<E47SemanticSlamResult | null>(null);
|
||||
const [semanticError, setSemanticError] = useState<string | null>(null);
|
||||
const [chunks, setChunks] = useState<ReadonlyMap<number, M49TgsFullShadowSpatialChunk>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const [playbackPack, setPlaybackPack] = useState<M49TgsFullShadowPlaybackPack | null>(null);
|
||||
const [playbackProgress, setPlaybackProgress] = useState<M49TgsFullShadowPlaybackProgress>({
|
||||
phase: "manifest",
|
||||
trackId: null,
|
||||
loadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inFlightRef = useRef(new Map<number, AbortController>());
|
||||
const activeChunkStart = activeSequence === null
|
||||
? null
|
||||
: Math.floor(activeSequence / CHUNK_FRAMES) * CHUNK_FRAMES;
|
||||
const activeChunkStartRef = useRef(activeChunkStart);
|
||||
activeChunkStartRef.current = activeChunkStart;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -85,65 +81,64 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
|
||||
}, [result.source.linkedSemanticResultId, result.source.linkedVisualResultId]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const controller of inFlightRef.current.values()) controller.abort();
|
||||
inFlightRef.current.clear();
|
||||
setChunks(new Map());
|
||||
const controller = new AbortController();
|
||||
setPlaybackPack(null);
|
||||
setPlaybackProgress({ phase: "manifest", trackId: null, loadedBytes: 0, totalBytes: 0 });
|
||||
setError(null);
|
||||
return () => {
|
||||
for (const controller of inFlightRef.current.values()) controller.abort();
|
||||
inFlightRef.current.clear();
|
||||
};
|
||||
void fetchM49TgsFullShadowPlaybackPack(result.resultId, {
|
||||
signal: controller.signal,
|
||||
onProgress: setPlaybackProgress,
|
||||
}).then((pack) => {
|
||||
if (!controller.signal.aborted) setPlaybackPack(pack);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) setError(message(caught));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeChunkStart === null) return;
|
||||
const desiredStarts = [activeChunkStart, activeChunkStart + CHUNK_FRAMES]
|
||||
.filter((start) => start < result.timeline.frameCount);
|
||||
const desired = new Set(desiredStarts);
|
||||
for (const [start, controller] of inFlightRef.current) {
|
||||
if (desired.has(start)) continue;
|
||||
controller.abort();
|
||||
inFlightRef.current.delete(start);
|
||||
}
|
||||
for (const start of desiredStarts) {
|
||||
if (chunks.has(start) || inFlightRef.current.has(start)) continue;
|
||||
const controller = new AbortController();
|
||||
inFlightRef.current.set(start, controller);
|
||||
void fetchM49TgsFullShadowSpatialChunk(result.resultId, start, CHUNK_FRAMES, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((chunk) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setChunks((current) => {
|
||||
const next = new Map(current);
|
||||
next.set(start, chunk);
|
||||
const center = activeChunkStartRef.current ?? start;
|
||||
const retained = [...next.keys()]
|
||||
.sort((left, right) => Math.abs(left - center) - Math.abs(right - center))
|
||||
.slice(0, RETAINED_CHUNKS);
|
||||
return new Map(retained.map((key) => [key, next.get(key)!]));
|
||||
});
|
||||
if (start === activeChunkStartRef.current) setError(null);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted && start === activeChunkStartRef.current) {
|
||||
setError(message(caught));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightRef.current.get(start) === controller) inFlightRef.current.delete(start);
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [activeChunkStart, chunks, result.resultId, result.timeline.frameCount]);
|
||||
|
||||
const spatial = useMemo<M49TgsFullShadowSpatial | null>(() => {
|
||||
if (activeSequence === null || activeChunkStart === null) return null;
|
||||
return chunks.get(activeChunkStart)?.frames.find(
|
||||
(frame) => frame.sourceSequence === activeSequence,
|
||||
) ?? null;
|
||||
}, [activeChunkStart, activeSequence, chunks]);
|
||||
if (activeSequence === null || !playbackPack) return null;
|
||||
const frame = playbackPack.frames[activeSequence];
|
||||
if (!frame) return null;
|
||||
const cellOffset = activeSequence * playbackPack.cellCount;
|
||||
const zOffset = cellOffset * 2;
|
||||
const states = Array.from(
|
||||
playbackPack.states.subarray(cellOffset, cellOffset + playbackPack.cellCount),
|
||||
(value) => value as M49TgsFullShadowStateCode,
|
||||
);
|
||||
const zBoundsM = Array.from({ length: playbackPack.cellCount }, (_, index) => {
|
||||
const minimum = playbackPack.zBoundsM[zOffset + index * 2]!;
|
||||
const maximum = playbackPack.zBoundsM[zOffset + index * 2 + 1]!;
|
||||
return [
|
||||
Number.isFinite(minimum) ? minimum : null,
|
||||
Number.isFinite(maximum) ? maximum : null,
|
||||
] as const;
|
||||
});
|
||||
return {
|
||||
resultId: playbackPack.resultId,
|
||||
sourceSequence: activeSequence,
|
||||
sourceFrameIndex: frame.sourceFrameIndex,
|
||||
sessionSeconds: frame.sessionSeconds,
|
||||
sampleAvailable: frame.sampleAvailable,
|
||||
costmap: {
|
||||
cellSizeM: result.configuration.cellSizeM,
|
||||
radiusM: result.configuration.radiusM,
|
||||
centersXyM: playbackPack.centersXyM,
|
||||
states,
|
||||
zBoundsM,
|
||||
},
|
||||
metrics: frame.metrics,
|
||||
};
|
||||
}, [activeSequence, playbackPack, result.configuration.cellSizeM, result.configuration.radiusM]);
|
||||
const loading = activeSequence !== null && !spatial && !error;
|
||||
const progressPercent = playbackProgress.totalBytes > 0
|
||||
? Math.min(100, Math.round((playbackProgress.loadedBytes / playbackProgress.totalBytes) * 100))
|
||||
: 0;
|
||||
const loadingLabel = playbackProgress.phase === "manifest"
|
||||
? "Проверяем playback manifest"
|
||||
: playbackProgress.phase === "ready"
|
||||
? "TGS playback готов"
|
||||
: `Подготавливаем TGS playback · ${progressPercent}% · ${(playbackProgress.loadedBytes / 1_048_576).toFixed(1)}/${(playbackProgress.totalBytes / 1_048_576).toFixed(1)} МБ`;
|
||||
|
||||
const classifiedFrame = useMemo<M4ReplayClassifiedSpatialFrame | null>(() => {
|
||||
if (!spatial) return null;
|
||||
@@ -188,6 +183,7 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
|
||||
expectedAtSequence: true,
|
||||
frame: classifiedFrame,
|
||||
loading,
|
||||
loadingLabel,
|
||||
error,
|
||||
replacePointCloud: false,
|
||||
}}
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface M4ReplayClassifiedSpatialLayer {
|
||||
expectedAtSequence: boolean;
|
||||
frame: M4ReplayClassifiedSpatialFrame | null;
|
||||
loading: boolean;
|
||||
loadingLabel?: string;
|
||||
error: string | null;
|
||||
replacePointCloud?: boolean;
|
||||
}
|
||||
@@ -850,7 +851,9 @@ export function M4ReplayThreatVisual({
|
||||
: activeSpatialFrame
|
||||
? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
|
||||
: "TGS рассчитан · linked source cloud недоступен для этого кадра"
|
||||
: classifiedSpatialLayer.error ?? `Открываем ${classifiedSpatialLayer.label}`
|
||||
: classifiedSpatialLayer.error
|
||||
?? classifiedSpatialLayer.loadingLabel
|
||||
?? `Открываем ${classifiedSpatialLayer.label}`
|
||||
: (
|
||||
<>
|
||||
{spatialFrame
|
||||
@@ -1022,7 +1025,7 @@ export function M4ReplayThreatVisual({
|
||||
: <Icon name="alert" size={18} />}
|
||||
<span>{classifiedSpatialLayer.error
|
||||
?? (classifiedSpatialLayer.loading || displayingBufferedFrame
|
||||
? `Открываем ${classifiedSpatialLayer.label}`
|
||||
? classifiedSpatialLayer.loadingLabel ?? `Открываем ${classifiedSpatialLayer.label}`
|
||||
: classifiedSpatialLayer.expectedAtSequence
|
||||
? `Открываем ${classifiedSpatialLayer.label}`
|
||||
: `${classifiedSpatialLayer.label} рассчитан только на 10 контрольных кадров.`)}</span>
|
||||
@@ -1071,7 +1074,7 @@ export function M4ReplayThreatVisual({
|
||||
{timelineFrame.loading || displayingBufferedFrame ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Догружаем следующий spatial-буфер без сброса сцены</span>
|
||||
<span>{timelineFrame.loadingLabel ?? "Догружаем следующий spatial-буфер без сброса сцены"}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{timelineFrame.error ? (
|
||||
|
||||
@@ -2,10 +2,14 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchM4ThreatCameraPointOverlay,
|
||||
fetchM4ThreatPlaybackPointPack,
|
||||
fetchM4ThreatTimeline,
|
||||
fetchM4ThreatTimelineChunk,
|
||||
M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||
selectM4ThreatTimelineSequence,
|
||||
type M4ThreatCameraPointOverlay,
|
||||
type M4ThreatPlaybackPointPack,
|
||||
type M4ThreatPlaybackProgress,
|
||||
type M4ThreatTimeline,
|
||||
type M4ThreatTimelineChunk,
|
||||
type M4ThreatTimelineFrame,
|
||||
@@ -82,11 +86,48 @@ export function useM4ThreatTimelineFrame({
|
||||
() => new Map(),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pointPack, setPointPack] = useState<M4ThreatPlaybackPointPack | null>(null);
|
||||
const [playbackProgress, setPlaybackProgress] = useState<M4ThreatPlaybackProgress>({
|
||||
phase: "manifest",
|
||||
loadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
});
|
||||
const [playbackError, setPlaybackError] = useState<string | null>(null);
|
||||
const binaryPlayback = endpointRoot === undefined
|
||||
|| endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT;
|
||||
const inFlight = useRef(new Map<number, AbortController>());
|
||||
const chunksRef = useRef(chunks);
|
||||
const activeChunkStartRef = useRef<number | null>(null);
|
||||
chunksRef.current = chunks;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setPointPack(null);
|
||||
setPlaybackError(null);
|
||||
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
||||
if (!timeline) return () => controller.abort();
|
||||
if (!binaryPlayback) {
|
||||
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
|
||||
return () => controller.abort();
|
||||
}
|
||||
void fetchM4ThreatPlaybackPointPack(resultId, {
|
||||
signal: controller.signal,
|
||||
endpointRoot,
|
||||
onProgress: (progress) => {
|
||||
if (!controller.signal.aborted) setPlaybackProgress(progress);
|
||||
},
|
||||
})
|
||||
.then((pack) => {
|
||||
if (!controller.signal.aborted) setPointPack(pack);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setPlaybackError(errorMessage(caught, "Бинарный spatial playback M4.6 недоступен."));
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [binaryPlayback, endpointRoot, resultId, timeline]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const controller of inFlight.current.values()) controller.abort();
|
||||
inFlight.current.clear();
|
||||
@@ -116,7 +157,7 @@ export function useM4ThreatTimelineFrame({
|
||||
activeChunkStartRef.current = activeChunkStart;
|
||||
|
||||
useEffect(() => {
|
||||
if (!timeline || activeChunkStart === null) return;
|
||||
if (!timeline || activeChunkStart === null || (binaryPlayback && !pointPack)) return;
|
||||
const starts = m4ThreatChunkWindowStarts(
|
||||
activeChunkStart,
|
||||
chunkSize,
|
||||
@@ -131,6 +172,7 @@ export function useM4ThreatTimelineFrame({
|
||||
signal: controller.signal,
|
||||
endpointRoot,
|
||||
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
|
||||
playbackPointPack: binaryPlayback ? pointPack ?? undefined : undefined,
|
||||
})
|
||||
.then((chunk) => {
|
||||
if (controller.signal.aborted) return;
|
||||
@@ -161,7 +203,7 @@ export function useM4ThreatTimelineFrame({
|
||||
// loaded first, then the next chunk is prefetched on the following render.
|
||||
break;
|
||||
}
|
||||
}, [activeChunkStart, chunkSize, chunks, endpointRoot, resultId, timeline]);
|
||||
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, pointPack, resultId, timeline]);
|
||||
|
||||
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
||||
if (activeSequence === null || activeChunkStart === null) return null;
|
||||
@@ -181,8 +223,17 @@ export function useM4ThreatTimelineFrame({
|
||||
activeSequence,
|
||||
activeFrame,
|
||||
availableFrames,
|
||||
loading: error === null && Boolean(timeline) && !activeFrame,
|
||||
error,
|
||||
loading: error === null && playbackError === null && Boolean(timeline) && !activeFrame,
|
||||
loadingLabel: !binaryPlayback
|
||||
? "Догружаем следующий spatial-буфер без сброса сцены"
|
||||
: playbackProgress.phase === "manifest"
|
||||
? "Проверяем M4 playback manifest"
|
||||
: playbackProgress.phase === "ready"
|
||||
? "Подготавливаем текущий spatial-кадр"
|
||||
: `${playbackProgress.phase === "verify" ? "Проверяем" : "Загружаем"} M4 spatial playback · ${playbackProgress.totalBytes > 0
|
||||
? `${Math.min(100, Math.round(playbackProgress.loadedBytes / playbackProgress.totalBytes * 100))}% · ${(playbackProgress.loadedBytes / 1_048_576).toFixed(1)}/${(playbackProgress.totalBytes / 1_048_576).toFixed(1)} МБ`
|
||||
: "0%"}`,
|
||||
error: playbackError ?? error,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ test("semantic evidence mask is GPU-colored, bounded, cancelled and object-conta
|
||||
assert.match(source, /Math\.min\(width \/ mask\.width, height \/ mask\.height\)/);
|
||||
assert.match(source, /decoded\.key !== expectedKey/);
|
||||
assert.match(source, /mask\?\.key === expectedKey/);
|
||||
assert.match(source, /lastReadyMaskRef/);
|
||||
assert.match(source, /data-state=\{failure \? "error" : exactMask \? "ready" : renderMask \? "stale" : "loading"\}/);
|
||||
assert.match(source, /recorded-evidence-semantic-mask-overlay__error/);
|
||||
assert.match(source, /role="alert"/);
|
||||
});
|
||||
@@ -53,6 +55,9 @@ test("metric evidence keeps missing semantic assignments as context and exposes
|
||||
assert.match(source, /classId === null \? undefined : colorsByClassId\.get\(classId\)/);
|
||||
assert.match(source, /data-decision="semantic"/);
|
||||
assert.match(source, /recordedEvidenceSemanticCssColor/);
|
||||
assert.match(source, /DynamicDrawUsage/);
|
||||
assert.match(source, /classifiedMeshesRef/);
|
||||
assert.match(source, /updatePointPositions/);
|
||||
});
|
||||
|
||||
test("semantic point alignment follows the last qualified spatial increment", async () => {
|
||||
|
||||
Reference in New Issue
Block a user