From 7aeea4ed817a62063f00aa7862d7502420683d5a Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 27 Aug 2026 15:19:00 +0300 Subject: [PATCH] perf(lab): retain spatial scenes during replay --- .../LaboratoryMetricEvidenceScene.tsx | 368 +++++++++++------- .../RecordedEvidenceSemanticMaskOverlay.tsx | 11 +- .../laboratory/M49TgsFullShadowEvidence.tsx | 134 ++++--- .../laboratory/M4ReplayThreatVisual.tsx | 9 +- .../laboratory/useM4ThreatTimeline.ts | 59 ++- .../test/semanticEvidencePrimitives.test.mjs | 5 + 6 files changed, 372 insertions(+), 214 deletions(-) diff --git a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx index 4c69adb..f14897c 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx @@ -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(null); const staticContentRef = useRef(null); const dynamicContentRef = useRef(null); + const classifiedContentRef = useRef(null); + const localSurfacePointsRef = useRef(null); + const currentIncrementPointsRef = useRef(null); + const classifiedMeshesRef = useRef>( + new Map(), + ); const [renderError, setRenderError] = useState(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(); + 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(); - 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(); - 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(); + 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(); + 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; diff --git a/apps/control-station/src/components/laboratory/RecordedEvidenceSemanticMaskOverlay.tsx b/apps/control-station/src/components/laboratory/RecordedEvidenceSemanticMaskOverlay.tsx index 094df92..c62f770 100644 --- a/apps/control-station/src/components/laboratory/RecordedEvidenceSemanticMaskOverlay.tsx +++ b/apps/control-station/src/components/laboratory/RecordedEvidenceSemanticMaskOverlay.tsx @@ -510,16 +510,21 @@ export function RecordedEvidenceSemanticMaskOverlay({ }) { const canvasRef = useRef(null); const rendererRef = useRef(undefined); + const lastReadyMaskRef = useRef<{ series: string; mask: DecodedSemanticMask } | null>(null); const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl"); const [mask, setMask] = useState(null); const [failure, setFailure] = useState(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 }} /> diff --git a/apps/control-station/src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx b/apps/control-station/src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx index 4c5d34a..56544ec 100644 --- a/apps/control-station/src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx +++ b/apps/control-station/src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx @@ -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(null); const [semantic, setSemantic] = useState(null); const [semanticError, setSemanticError] = useState(null); - const [chunks, setChunks] = useState>( - () => new Map(), - ); + const [playbackPack, setPlaybackPack] = useState(null); + const [playbackProgress, setPlaybackProgress] = useState({ + phase: "manifest", + trackId: null, + loadedBytes: 0, + totalBytes: 0, + }); const [error, setError] = useState(null); - const inFlightRef = useRef(new Map()); - 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(() => { - 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(() => { if (!spatial) return null; @@ -188,6 +183,7 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR expectedAtSequence: true, frame: classifiedFrame, loading, + loadingLabel, error, replacePointCloud: false, }} diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index 6bab793..f3b4ea8 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -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({ : } {classifiedSpatialLayer.error ?? (classifiedSpatialLayer.loading || displayingBufferedFrame - ? `Открываем ${classifiedSpatialLayer.label}` + ? classifiedSpatialLayer.loadingLabel ?? `Открываем ${classifiedSpatialLayer.label}` : classifiedSpatialLayer.expectedAtSequence ? `Открываем ${classifiedSpatialLayer.label}` : `${classifiedSpatialLayer.label} рассчитан только на 10 контрольных кадров.`)} @@ -1071,7 +1074,7 @@ export function M4ReplayThreatVisual({ {timelineFrame.loading || displayingBufferedFrame ? (
) : null} {timelineFrame.error ? ( diff --git a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts index a42506a..6a41838 100644 --- a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts +++ b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts @@ -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(null); + const [pointPack, setPointPack] = useState(null); + const [playbackProgress, setPlaybackProgress] = useState({ + phase: "manifest", + loadedBytes: 0, + totalBytes: 0, + }); + const [playbackError, setPlaybackError] = useState(null); + const binaryPlayback = endpointRoot === undefined + || endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT; const inFlight = useRef(new Map()); const chunksRef = useRef(chunks); const activeChunkStartRef = useRef(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, }; } diff --git a/apps/control-station/test/semanticEvidencePrimitives.test.mjs b/apps/control-station/test/semanticEvidencePrimitives.test.mjs index 9ffad32..f7ea90a 100644 --- a/apps/control-station/test/semanticEvidencePrimitives.test.mjs +++ b/apps/control-station/test/semanticEvidencePrimitives.test.mjs @@ -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 () => {