perf(lab): retain spatial scenes during replay

This commit is contained in:
DCCONSTRUCTIONS
2026-08-27 15:19:00 +03:00
parent bf76304e00
commit 7aeea4ed81
6 changed files with 372 additions and 214 deletions
@@ -169,6 +169,47 @@ function positions(points: readonly LaboratoryMetricPoint3[]): Float32Array {
return result; 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( function decisionColor(
host: HTMLElement, host: HTMLElement,
decision: LaboratoryMetricDecision, decision: LaboratoryMetricDecision,
@@ -244,6 +285,12 @@ LaboratoryMetricEvidenceSceneHandle,
const controlsRef = useRef<OrbitControls | null>(null); const controlsRef = useRef<OrbitControls | null>(null);
const staticContentRef = useRef<THREE.Group | null>(null); const staticContentRef = useRef<THREE.Group | null>(null);
const dynamicContentRef = 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); const [renderError, setRenderError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -278,12 +325,48 @@ LaboratoryMetricEvidenceSceneHandle,
controls.maxDistance = 80; controls.maxDistance = 80;
const staticContent = new THREE.Group(); const staticContent = new THREE.Group();
const dynamicContent = 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; sceneRef.current = scene;
cameraRef.current = camera; cameraRef.current = camera;
controlsRef.current = controls; controlsRef.current = controls;
staticContentRef.current = staticContent; staticContentRef.current = staticContent;
dynamicContentRef.current = dynamicContent; dynamicContentRef.current = dynamicContent;
classifiedContentRef.current = classifiedContent;
localSurfacePointsRef.current = localSurfacePoints;
currentIncrementPointsRef.current = currentIncrementPoints;
const resize = () => { const resize = () => {
const width = Math.max(host.clientWidth, 1); const width = Math.max(host.clientWidth, 1);
@@ -315,6 +398,10 @@ LaboratoryMetricEvidenceSceneHandle,
controlsRef.current = null; controlsRef.current = null;
staticContentRef.current = null; staticContentRef.current = null;
dynamicContentRef.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); if (canvas) canvas.setAttribute("aria-label", 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(() => { useEffect(() => {
const host = hostRef.current; const host = hostRef.current;
const content = dynamicContentRef.current; const content = dynamicContentRef.current;
if (!host || !content) return; if (!host || !content) return;
clearGroup(content); 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) { for (const obstacle of obstacles) {
if ( if (
( (
@@ -526,20 +547,97 @@ LaboratoryMetricEvidenceSceneHandle,
}, [ }, [
obstacles, obstacles,
occupiedVoxelSizeM, occupiedVoxelSizeM,
localSurfaceBodyXyzM,
pointCloudBodyXyzM,
pointSemanticClassIds,
semanticClasses,
semanticPalette,
classifiedCells,
classifiedCellSizeM,
showCurrentIncrement, showCurrentIncrement,
showClassifiedCells,
showLocalSurface,
showRollingMap, showRollingMap,
showLowStep, 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(() => { useEffect(() => {
const host = hostRef.current; const host = hostRef.current;
const content = staticContentRef.current; const content = staticContentRef.current;
@@ -510,16 +510,21 @@ export function RecordedEvidenceSemanticMaskOverlay({
}) { }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const rendererRef = useRef<SemanticMaskRenderer | null | undefined>(undefined); const rendererRef = useRef<SemanticMaskRenderer | null | undefined>(undefined);
const lastReadyMaskRef = useRef<{ series: string; mask: DecodedSemanticMask } | null>(null);
const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl"); const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl");
const [mask, setMask] = useState<DecodedSemanticMask | null>(null); const [mask, setMask] = useState<DecodedSemanticMask | null>(null);
const [failure, setFailure] = useState<string | null>(null); const [failure, setFailure] = useState<string | null>(null);
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight); const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
const prefetchSignature = prefetchSrcs.join("\n"); 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 ? null
: mask?.key === expectedKey : mask?.key === expectedKey
? mask ? mask
: decodedMaskCache.get(expectedKey) ?? null; : decodedMaskCache.get(expectedKey) ?? null;
if (exactMask) lastReadyMaskRef.current = { series, mask: exactMask };
const renderMask = exactMask ?? lastReadyMaskRef.current?.mask ?? null;
useEffect(() => { useEffect(() => {
setFailure(null); setFailure(null);
@@ -627,8 +632,8 @@ export function RecordedEvidenceSemanticMaskOverlay({
className="recorded-evidence-semantic-mask-overlay" className="recorded-evidence-semantic-mask-overlay"
role="img" role="img"
aria-label={ariaLabel} aria-label={ariaLabel}
aria-busy={!failure && !renderMask} aria-busy={!failure && !exactMask}
data-state={failure ? "error" : renderMask ? "ready" : "loading"} data-state={failure ? "error" : exactMask ? "ready" : renderMask ? "stale" : "loading"}
data-renderer={rendererMode} data-renderer={rendererMode}
style={{ zIndex: 1 }} style={{ zIndex: 1 }}
/> />
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import type { import type {
RecordedEvidenceSemanticClass, RecordedEvidenceSemanticClass,
@@ -9,10 +9,11 @@ import {
type E47SemanticSlamResult, type E47SemanticSlamResult,
} from "../../core/laboratory/e47SemanticSlam"; } from "../../core/laboratory/e47SemanticSlam";
import { import {
fetchM49TgsFullShadowSpatialChunk, fetchM49TgsFullShadowPlaybackPack,
type M49TgsFullShadowResult, type M49TgsFullShadowResult,
type M49TgsFullShadowPlaybackPack,
type M49TgsFullShadowPlaybackProgress,
type M49TgsFullShadowSpatial, type M49TgsFullShadowSpatial,
type M49TgsFullShadowSpatialChunk,
type M49TgsFullShadowStateCode, type M49TgsFullShadowStateCode,
} from "../../core/laboratory/m49TgsFullShadow"; } from "../../core/laboratory/m49TgsFullShadow";
import { import {
@@ -32,9 +33,6 @@ const PALETTE: readonly RecordedEvidenceSemanticPaletteEntry[] = [
{ classId: 3, color: { kind: "token", token: "--nodedc-warning-rgb" } }, { 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"] { function cellState(code: M49TgsFullShadowStateCode): M4ReplayClassifiedSpatialFrame["cellsMapGravityLocal"][number]["state"] {
if (code === 1) return "ground-support"; if (code === 1) return "ground-support";
if (code === 2) return "nonground-occupied"; if (code === 2) return "nonground-occupied";
@@ -52,16 +50,14 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
const [activeSequence, setActiveSequence] = useState<number | null>(null); const [activeSequence, setActiveSequence] = useState<number | null>(null);
const [semantic, setSemantic] = useState<E47SemanticSlamResult | null>(null); const [semantic, setSemantic] = useState<E47SemanticSlamResult | null>(null);
const [semanticError, setSemanticError] = useState<string | null>(null); const [semanticError, setSemanticError] = useState<string | null>(null);
const [chunks, setChunks] = useState<ReadonlyMap<number, M49TgsFullShadowSpatialChunk>>( const [playbackPack, setPlaybackPack] = useState<M49TgsFullShadowPlaybackPack | null>(null);
() => new Map(), const [playbackProgress, setPlaybackProgress] = useState<M49TgsFullShadowPlaybackProgress>({
); phase: "manifest",
trackId: null,
loadedBytes: 0,
totalBytes: 0,
});
const [error, setError] = useState<string | null>(null); 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(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -85,65 +81,64 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
}, [result.source.linkedSemanticResultId, result.source.linkedVisualResultId]); }, [result.source.linkedSemanticResultId, result.source.linkedVisualResultId]);
useEffect(() => { useEffect(() => {
for (const controller of inFlightRef.current.values()) controller.abort(); const controller = new AbortController();
inFlightRef.current.clear(); setPlaybackPack(null);
setChunks(new Map()); setPlaybackProgress({ phase: "manifest", trackId: null, loadedBytes: 0, totalBytes: 0 });
setError(null); setError(null);
return () => { void fetchM49TgsFullShadowPlaybackPack(result.resultId, {
for (const controller of inFlightRef.current.values()) controller.abort(); signal: controller.signal,
inFlightRef.current.clear(); 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]); }, [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>(() => { const spatial = useMemo<M49TgsFullShadowSpatial | null>(() => {
if (activeSequence === null || activeChunkStart === null) return null; if (activeSequence === null || !playbackPack) return null;
return chunks.get(activeChunkStart)?.frames.find( const frame = playbackPack.frames[activeSequence];
(frame) => frame.sourceSequence === activeSequence, if (!frame) return null;
) ?? null; const cellOffset = activeSequence * playbackPack.cellCount;
}, [activeChunkStart, activeSequence, chunks]); 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 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>(() => { const classifiedFrame = useMemo<M4ReplayClassifiedSpatialFrame | null>(() => {
if (!spatial) return null; if (!spatial) return null;
@@ -188,6 +183,7 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
expectedAtSequence: true, expectedAtSequence: true,
frame: classifiedFrame, frame: classifiedFrame,
loading, loading,
loadingLabel,
error, error,
replacePointCloud: false, replacePointCloud: false,
}} }}
@@ -130,6 +130,7 @@ export interface M4ReplayClassifiedSpatialLayer {
expectedAtSequence: boolean; expectedAtSequence: boolean;
frame: M4ReplayClassifiedSpatialFrame | null; frame: M4ReplayClassifiedSpatialFrame | null;
loading: boolean; loading: boolean;
loadingLabel?: string;
error: string | null; error: string | null;
replacePointCloud?: boolean; replacePointCloud?: boolean;
} }
@@ -850,7 +851,9 @@ export function M4ReplayThreatVisual({
: activeSpatialFrame : activeSpatialFrame
? "map-gravity-local · all eligible points accounted · causal rolling 1 s" ? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
: "TGS рассчитан · linked source cloud недоступен для этого кадра" : "TGS рассчитан · linked source cloud недоступен для этого кадра"
: classifiedSpatialLayer.error ?? `Открываем ${classifiedSpatialLayer.label}` : classifiedSpatialLayer.error
?? classifiedSpatialLayer.loadingLabel
?? `Открываем ${classifiedSpatialLayer.label}`
: ( : (
<> <>
{spatialFrame {spatialFrame
@@ -1022,7 +1025,7 @@ export function M4ReplayThreatVisual({
: <Icon name="alert" size={18} />} : <Icon name="alert" size={18} />}
<span>{classifiedSpatialLayer.error <span>{classifiedSpatialLayer.error
?? (classifiedSpatialLayer.loading || displayingBufferedFrame ?? (classifiedSpatialLayer.loading || displayingBufferedFrame
? `Открываем ${classifiedSpatialLayer.label}` ? classifiedSpatialLayer.loadingLabel ?? `Открываем ${classifiedSpatialLayer.label}`
: classifiedSpatialLayer.expectedAtSequence : classifiedSpatialLayer.expectedAtSequence
? `Открываем ${classifiedSpatialLayer.label}` ? `Открываем ${classifiedSpatialLayer.label}`
: `${classifiedSpatialLayer.label} рассчитан только на 10 контрольных кадров.`)}</span> : `${classifiedSpatialLayer.label} рассчитан только на 10 контрольных кадров.`)}</span>
@@ -1071,7 +1074,7 @@ export function M4ReplayThreatVisual({
{timelineFrame.loading || displayingBufferedFrame ? ( {timelineFrame.loading || displayingBufferedFrame ? (
<div className="m4-replay-threat-visual__buffering" role="status"> <div className="m4-replay-threat-visual__buffering" role="status">
<span className="busy-indicator" aria-hidden="true" /> <span className="busy-indicator" aria-hidden="true" />
<span>Догружаем следующий spatial-буфер без сброса сцены</span> <span>{timelineFrame.loadingLabel ?? "Догружаем следующий spatial-буфер без сброса сцены"}</span>
</div> </div>
) : null} ) : null}
{timelineFrame.error ? ( {timelineFrame.error ? (
@@ -2,10 +2,14 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { import {
fetchM4ThreatCameraPointOverlay, fetchM4ThreatCameraPointOverlay,
fetchM4ThreatPlaybackPointPack,
fetchM4ThreatTimeline, fetchM4ThreatTimeline,
fetchM4ThreatTimelineChunk, fetchM4ThreatTimelineChunk,
M4_THREAT_TIMELINE_ENDPOINT_ROOT,
selectM4ThreatTimelineSequence, selectM4ThreatTimelineSequence,
type M4ThreatCameraPointOverlay, type M4ThreatCameraPointOverlay,
type M4ThreatPlaybackPointPack,
type M4ThreatPlaybackProgress,
type M4ThreatTimeline, type M4ThreatTimeline,
type M4ThreatTimelineChunk, type M4ThreatTimelineChunk,
type M4ThreatTimelineFrame, type M4ThreatTimelineFrame,
@@ -82,11 +86,48 @@ export function useM4ThreatTimelineFrame({
() => new Map(), () => new Map(),
); );
const [error, setError] = useState<string | null>(null); 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 inFlight = useRef(new Map<number, AbortController>());
const chunksRef = useRef(chunks); const chunksRef = useRef(chunks);
const activeChunkStartRef = useRef<number | null>(null); const activeChunkStartRef = useRef<number | null>(null);
chunksRef.current = chunks; 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(() => { useEffect(() => {
for (const controller of inFlight.current.values()) controller.abort(); for (const controller of inFlight.current.values()) controller.abort();
inFlight.current.clear(); inFlight.current.clear();
@@ -116,7 +157,7 @@ export function useM4ThreatTimelineFrame({
activeChunkStartRef.current = activeChunkStart; activeChunkStartRef.current = activeChunkStart;
useEffect(() => { useEffect(() => {
if (!timeline || activeChunkStart === null) return; if (!timeline || activeChunkStart === null || (binaryPlayback && !pointPack)) return;
const starts = m4ThreatChunkWindowStarts( const starts = m4ThreatChunkWindowStarts(
activeChunkStart, activeChunkStart,
chunkSize, chunkSize,
@@ -131,6 +172,7 @@ export function useM4ThreatTimelineFrame({
signal: controller.signal, signal: controller.signal,
endpointRoot, endpointRoot,
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery, cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
playbackPointPack: binaryPlayback ? pointPack ?? undefined : undefined,
}) })
.then((chunk) => { .then((chunk) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
@@ -161,7 +203,7 @@ export function useM4ThreatTimelineFrame({
// loaded first, then the next chunk is prefetched on the following render. // loaded first, then the next chunk is prefetched on the following render.
break; break;
} }
}, [activeChunkStart, chunkSize, chunks, endpointRoot, resultId, timeline]); }, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, pointPack, resultId, timeline]);
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => { const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
if (activeSequence === null || activeChunkStart === null) return null; if (activeSequence === null || activeChunkStart === null) return null;
@@ -181,8 +223,17 @@ export function useM4ThreatTimelineFrame({
activeSequence, activeSequence,
activeFrame, activeFrame,
availableFrames, availableFrames,
loading: error === null && Boolean(timeline) && !activeFrame, loading: error === null && playbackError === null && Boolean(timeline) && !activeFrame,
error, 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, /Math\.min\(width \/ mask\.width, height \/ mask\.height\)/);
assert.match(source, /decoded\.key !== expectedKey/); assert.match(source, /decoded\.key !== expectedKey/);
assert.match(source, /mask\?\.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, /recorded-evidence-semantic-mask-overlay__error/);
assert.match(source, /role="alert"/); 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, /classId === null \? undefined : colorsByClassId\.get\(classId\)/);
assert.match(source, /data-decision="semantic"/); assert.match(source, /data-decision="semantic"/);
assert.match(source, /recordedEvidenceSemanticCssColor/); 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 () => { test("semantic point alignment follows the last qualified spatial increment", async () => {