feat(lab): stabilize autonomous TGS playback
This commit is contained in:
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user