feat(lab): stabilize autonomous TGS playback

This commit is contained in:
DCCONSTRUCTIONS
2026-08-27 20:34:20 +03:00
parent c8593207d3
commit 95a1ef5057
26 changed files with 3326 additions and 253 deletions
@@ -352,6 +352,7 @@ interface RecordedSegmentStreamRuntime {
target: RecordedSegmentTarget | null;
fetchAbort: AbortController | null;
notifiedRevision: number;
hasPresentedFrame: boolean;
pumping: boolean;
disposed: boolean;
onTargetBuffered: ((target: RecordedSegmentTarget) => void) | null;
@@ -944,6 +945,7 @@ export function RecordedFmp4Player({
target: null,
fetchAbort: null,
notifiedRevision: 0,
hasPresentedFrame: false,
pumping: false,
disposed: false,
onTargetBuffered: null,
@@ -1090,8 +1092,13 @@ export function RecordedFmp4Player({
runtime.fetchAbort?.abort();
runtime.target = target;
const alreadyBuffered = recordedSegmentTargetBuffered(runtime, target);
const retainForwardFrame = Boolean(
runtime.hasPresentedFrame
&& previousTarget
&& candidateTarget.sequence >= previousTarget.sequence,
);
video.pause();
if (!alreadyBuffered) {
if (!alreadyBuffered && !retainForwardFrame) {
setReadyGeneration(null);
setState("loading");
}
@@ -1119,6 +1126,7 @@ export function RecordedFmp4Player({
|| runtime.target?.revision !== bufferedTarget.revision
) return;
setBufferRevision((revision) => revision + 1);
runtime.hasPresentedFrame = true;
setReadyGeneration(runtime.generation);
setState("ready");
reportAdmission({
@@ -1,6 +1,7 @@
import {
forwardRef,
type CSSProperties,
useCallback,
useEffect,
useImperativeHandle,
useRef,
@@ -53,6 +54,12 @@ export interface LaboratoryMetricCellEvidence {
state: LaboratoryMetricCellState;
}
export interface LaboratoryMetricPackedCellEvidence {
centersBodyXyM: Float32Array;
zBoundsM: Float32Array;
stateCodes: Uint8Array;
}
export interface LaboratoryMetricLegendEntry {
id: LaboratoryMetricDecision
| LaboratoryMetricCellState
@@ -63,6 +70,13 @@ export interface LaboratoryMetricLegendEntry {
label: string;
}
interface LaboratoryMetricRenderStats {
frameMs: number;
drawCalls: number;
triangles: number;
pixelRatio: number;
}
export function laboratoryMetricLegendEntries({
pointCloudCount,
localSurfaceCount,
@@ -256,6 +270,7 @@ LaboratoryMetricEvidenceSceneHandle,
semanticClasses?: readonly RecordedEvidenceSemanticClass[];
semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[];
classifiedCells?: readonly LaboratoryMetricCellEvidence[];
classifiedPackedCells?: LaboratoryMetricPackedCellEvidence;
classifiedCellSizeM?: number;
showClassifiedCells?: boolean;
}
@@ -276,6 +291,7 @@ LaboratoryMetricEvidenceSceneHandle,
semanticClasses,
semanticPalette,
classifiedCells = [],
classifiedPackedCells,
classifiedCellSizeM = 0.45,
showClassifiedCells = true,
}, ref) {
@@ -288,10 +304,16 @@ LaboratoryMetricEvidenceSceneHandle,
const classifiedContentRef = useRef<THREE.Group | null>(null);
const localSurfacePointsRef = useRef<THREE.Points | null>(null);
const currentIncrementPointsRef = useRef<THREE.Points | null>(null);
const requestRenderRef = useRef<() => void>(() => undefined);
const corridorForwardLengthRef = useRef(corridor.forwardLengthM);
const modeRef = useRef(mode);
const classifiedMeshesRef = useRef<ReadonlyMap<LaboratoryMetricCellState, THREE.InstancedMesh>>(
new Map(),
);
const [renderError, setRenderError] = useState<string | null>(null);
const [renderStats, setRenderStats] = useState<LaboratoryMetricRenderStats | null>(null);
corridorForwardLengthRef.current = corridor.forwardLengthM;
modeRef.current = mode;
useEffect(() => {
const host = hostRef.current;
@@ -368,27 +390,51 @@ LaboratoryMetricEvidenceSceneHandle,
localSurfacePointsRef.current = localSurfacePoints;
currentIncrementPointsRef.current = currentIncrementPoints;
let animationFrame: number | null = null;
let lastStatsAt = Number.NEGATIVE_INFINITY;
const render = () => {
animationFrame = null;
// OrbitControls emits another change while damping is still settling, so
// this remains smooth during interaction without rendering forever while
// the evidence scene is idle.
controls.update();
const startedAt = performance.now();
renderer.render(scene, camera);
const completedAt = performance.now();
if (completedAt - lastStatsAt >= 1_000) {
lastStatsAt = completedAt;
setRenderStats({
frameMs: completedAt - startedAt,
drawCalls: renderer.info.render.calls,
triangles: renderer.info.render.triangles,
pixelRatio: renderer.getPixelRatio(),
});
}
};
const requestRender = () => {
if (animationFrame !== null) return;
animationFrame = window.requestAnimationFrame(render);
};
requestRenderRef.current = requestRender;
controls.addEventListener("change", requestRender);
const resize = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
requestRender();
};
const observer = new ResizeObserver(resize);
observer.observe(host);
resize();
let animationFrame = 0;
const render = () => {
animationFrame = window.requestAnimationFrame(render);
controls.update();
renderer.render(scene, camera);
};
render();
requestRender();
return () => {
window.cancelAnimationFrame(animationFrame);
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame);
observer.disconnect();
controls.removeEventListener("change", requestRender);
controls.dispose();
scene.traverse(disposeRenderable);
renderer.dispose();
@@ -401,6 +447,7 @@ LaboratoryMetricEvidenceSceneHandle,
classifiedContentRef.current = null;
localSurfacePointsRef.current = null;
currentIncrementPointsRef.current = null;
requestRenderRef.current = () => undefined;
classifiedMeshesRef.current = new Map();
};
}, []);
@@ -415,6 +462,7 @@ LaboratoryMetricEvidenceSceneHandle,
if (!points) return;
points.visible = showLocalSurface && localSurfaceBodyXyzM.length > 0;
if (points.visible) updatePointPositions(points.geometry, localSurfaceBodyXyzM);
requestRenderRef.current();
}, [localSurfaceBodyXyzM, showLocalSurface]);
useEffect(() => {
@@ -422,7 +470,10 @@ LaboratoryMetricEvidenceSceneHandle,
const points = currentIncrementPointsRef.current;
if (!host || !points) return;
points.visible = showCurrentIncrement && pointCloudBodyXyzM.length > 0;
if (!points.visible) return;
if (!points.visible) {
requestRenderRef.current();
return;
}
updatePointPositions(points.geometry, pointCloudBodyXyzM);
const material = points.material as THREE.PointsMaterial;
const hasAlignedSemanticClasses =
@@ -436,6 +487,7 @@ LaboratoryMetricEvidenceSceneHandle,
material.needsUpdate = true;
}
material.color.copy(tokenColor(host, "--nodedc-text-muted", [147, 151, 159]));
requestRenderRef.current();
return;
}
const declaredIds = new Set(semanticClasses.map((item) => item.id));
@@ -461,6 +513,7 @@ LaboratoryMetricEvidenceSceneHandle,
material.needsUpdate = true;
}
material.color.setRGB(1, 1, 1);
requestRenderRef.current();
}, [
pointCloudBodyXyzM,
pointSemanticClassIds,
@@ -543,7 +596,7 @@ LaboratoryMetricEvidenceSceneHandle,
content.add(centroid);
}
}
requestRenderRef.current();
}, [
obstacles,
occupiedVoxelSizeM,
@@ -556,10 +609,21 @@ LaboratoryMetricEvidenceSceneHandle,
const host = hostRef.current;
const content = classifiedContentRef.current;
if (!host || !content) return;
content.visible = showClassifiedCells && classifiedCells.length > 0;
if (!content.visible) return;
const packedCellCount = classifiedPackedCells?.stateCodes.length ?? 0;
const packedCellsValid = !classifiedPackedCells || (
classifiedPackedCells.centersBodyXyM.length === packedCellCount * 2
&& classifiedPackedCells.zBoundsM.length === packedCellCount * 2
);
const cellCount = packedCellsValid && classifiedPackedCells
? packedCellCount
: classifiedCells.length;
content.visible = showClassifiedCells && cellCount > 0;
if (!content.visible) {
requestRenderRef.current();
return;
}
const requiredCapacity = classifiedCells.length;
const requiredCapacity = cellCount;
const currentMeshes = classifiedMeshesRef.current;
const firstMesh = currentMeshes.values().next().value as THREE.InstancedMesh | undefined;
const needsAllocation = !firstMesh
@@ -609,12 +673,16 @@ LaboratoryMetricEvidenceSceneHandle,
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 setCell = (
state: LaboratoryMetricCellState,
centerX: number,
centerY: number,
minimum: number | null,
maximum: number | null,
) => {
const mesh = meshes.get(state);
if (!mesh) return;
const index = counts.get(state) ?? 0;
const height = minimum === null || maximum === null
? 0.018
: Math.max(0.018, maximum - minimum);
@@ -622,21 +690,55 @@ LaboratoryMetricEvidenceSceneHandle,
? -0.012
: (minimum + maximum) / 2;
const [sceneX, sceneY, sceneZ] = scenePoint([
cell.centerBodyXyM[0],
cell.centerBodyXyM[1],
centerX,
centerY,
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);
counts.set(state, index + 1);
};
if (packedCellsValid && classifiedPackedCells) {
for (let cellIndex = 0; cellIndex < packedCellCount; cellIndex += 1) {
const code = classifiedPackedCells.stateCodes[cellIndex];
const state: LaboratoryMetricCellState = code === 1
? "ground-support"
: code === 2
? "nonground-occupied"
: code === 3
? "unknown-rejected"
: code === 0
? "unobserved"
: "unknown-rejected";
const minimum = classifiedPackedCells.zBoundsM[cellIndex * 2]!;
const maximum = classifiedPackedCells.zBoundsM[cellIndex * 2 + 1]!;
setCell(
state,
classifiedPackedCells.centersBodyXyM[cellIndex * 2]!,
classifiedPackedCells.centersBodyXyM[cellIndex * 2 + 1]!,
Number.isFinite(minimum) ? minimum : null,
Number.isFinite(maximum) ? maximum : null,
);
}
} else {
for (const cell of classifiedCells) {
setCell(
cell.state,
cell.centerBodyXyM[0],
cell.centerBodyXyM[1],
cell.zBoundsM[0],
cell.zBoundsM[1],
);
}
}
for (const [state, mesh] of meshes) {
mesh.count = counts.get(state) ?? 0;
mesh.instanceMatrix.needsUpdate = true;
}
}, [classifiedCellSizeM, classifiedCells, showClassifiedCells]);
requestRenderRef.current();
}, [classifiedCellSizeM, classifiedCells, classifiedPackedCells, showClassifiedCells]);
useEffect(() => {
const host = hostRef.current;
@@ -702,15 +804,24 @@ LaboratoryMetricEvidenceSceneHandle,
material.depthWrite = false;
});
content.add(grid);
}, [corridor, rig]);
requestRenderRef.current();
}, [
corridor.forwardLengthM,
corridor.halfWidthM,
corridor.rearMarginM,
rig.lengthM,
rig.nominalSensorHeightM,
rig.widthM,
]);
const resetView = () => {
const resetView = useCallback(() => {
const camera = cameraRef.current;
const controls = controlsRef.current;
if (!camera || !controls) return;
controls.target.set(corridor.forwardLengthM * 0.35, 0.6, 0);
if (mode === "plan") {
camera.position.set(corridor.forwardLengthM * 0.35, 15, 0.001);
const forwardLengthM = corridorForwardLengthRef.current;
controls.target.set(forwardLengthM * 0.35, 0.6, 0);
if (modeRef.current === "plan") {
camera.position.set(forwardLengthM * 0.35, 15, 0.001);
camera.up.set(0, 0, -1);
} else {
camera.position.set(-4.5, 4.8, 8.5);
@@ -718,10 +829,13 @@ LaboratoryMetricEvidenceSceneHandle,
}
camera.updateProjectionMatrix();
controls.update();
};
requestRenderRef.current();
}, []);
useEffect(resetView, [corridor.forwardLengthM, mode]);
useImperativeHandle(ref, () => ({ resetView }));
// Playback data and rig object identities must never reset an operator's
// orbit. Only a deliberate 3D/PLAN mode transition chooses a default view.
useEffect(() => resetView(), [mode, resetView]);
useImperativeHandle(ref, () => ({ resetView }), [resetView]);
const semanticLegendEntries = (() => {
if (
@@ -755,8 +869,23 @@ LaboratoryMetricEvidenceSceneHandle,
showLowStep,
});
const classifiedLegendEntries = (() => {
if (!showClassifiedCells || !classifiedCells.length) return [];
const states = new Set(classifiedCells.map((cell) => cell.state));
const states = new Set<LaboratoryMetricCellState>();
if (classifiedPackedCells) {
for (const code of classifiedPackedCells.stateCodes) {
states.add(code === 1
? "ground-support"
: code === 2
? "nonground-occupied"
: code === 3
? "unknown-rejected"
: code === 0
? "unobserved"
: "unknown-rejected");
}
} else {
classifiedCells.forEach((cell) => states.add(cell.state));
}
if (!showClassifiedCells || !states.size) return [];
return [
states.has("ground-support")
? { id: "ground-support" as const, label: "Ground support" }
@@ -783,6 +912,16 @@ LaboratoryMetricEvidenceSceneHandle,
{renderError ? <p>{renderError}</p> : null}
</div>
<div className="laboratory-metric-evidence-scene__legend">
{renderStats ? (
<span
data-decision="performance"
title="CPU-время отправки последнего WebGL render pass; это не GPU timer. В покое сцена не перерисовывается."
>
3D {renderStats.frameMs.toFixed(1)} ms · {renderStats.drawCalls} draw · {renderStats.triangles.toLocaleString("ru-RU")} tri · {(
classifiedPackedCells?.stateCodes.length ?? classifiedCells.length
).toLocaleString("ru-RU")} cells · {renderStats.pixelRatio.toFixed(1)}×
</span>
) : null}
{metricLegendEntries.map((entry) => (
<span key={entry.id} data-decision={entry.id}>{entry.label}</span>
))}