perf(lab): retain spatial scenes during replay
This commit is contained in:
+233
-135
@@ -169,6 +169,47 @@ function positions(points: readonly LaboratoryMetricPoint3[]): Float32Array {
|
||||
return result;
|
||||
}
|
||||
|
||||
function updatePointPositions(
|
||||
geometry: THREE.BufferGeometry,
|
||||
points: readonly LaboratoryMetricPoint3[],
|
||||
): void {
|
||||
const requiredValues = points.length * 3;
|
||||
let attribute = geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
|
||||
if (!attribute || attribute.array.length < requiredValues) {
|
||||
let capacity = 3;
|
||||
while (capacity < requiredValues) capacity *= 2;
|
||||
attribute = new THREE.BufferAttribute(new Float32Array(capacity), 3);
|
||||
attribute.setUsage(THREE.DynamicDrawUsage);
|
||||
geometry.setAttribute("position", attribute);
|
||||
}
|
||||
const values = attribute.array as Float32Array;
|
||||
points.forEach((point, index) => {
|
||||
const [x, y, z] = scenePoint(point);
|
||||
const offset = index * 3;
|
||||
values[offset] = x;
|
||||
values[offset + 1] = y;
|
||||
values[offset + 2] = z;
|
||||
});
|
||||
attribute.needsUpdate = true;
|
||||
geometry.setDrawRange(0, points.length);
|
||||
}
|
||||
|
||||
function ensurePointColors(
|
||||
geometry: THREE.BufferGeometry,
|
||||
pointCount: number,
|
||||
): THREE.BufferAttribute {
|
||||
const requiredValues = pointCount * 3;
|
||||
let attribute = geometry.getAttribute("color") as THREE.BufferAttribute | undefined;
|
||||
if (!attribute || attribute.array.length < requiredValues) {
|
||||
let capacity = 3;
|
||||
while (capacity < requiredValues) capacity *= 2;
|
||||
attribute = new THREE.BufferAttribute(new Float32Array(capacity), 3);
|
||||
attribute.setUsage(THREE.DynamicDrawUsage);
|
||||
geometry.setAttribute("color", attribute);
|
||||
}
|
||||
return attribute;
|
||||
}
|
||||
|
||||
function decisionColor(
|
||||
host: HTMLElement,
|
||||
decision: LaboratoryMetricDecision,
|
||||
@@ -244,6 +285,12 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const staticContentRef = useRef<THREE.Group | null>(null);
|
||||
const dynamicContentRef = useRef<THREE.Group | null>(null);
|
||||
const classifiedContentRef = useRef<THREE.Group | null>(null);
|
||||
const localSurfacePointsRef = useRef<THREE.Points | null>(null);
|
||||
const currentIncrementPointsRef = useRef<THREE.Points | null>(null);
|
||||
const classifiedMeshesRef = useRef<ReadonlyMap<LaboratoryMetricCellState, THREE.InstancedMesh>>(
|
||||
new Map(),
|
||||
);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -278,12 +325,48 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
controls.maxDistance = 80;
|
||||
const staticContent = new THREE.Group();
|
||||
const dynamicContent = new THREE.Group();
|
||||
scene.add(staticContent, dynamicContent);
|
||||
const classifiedContent = new THREE.Group();
|
||||
const localSurfacePoints = new THREE.Points(
|
||||
new THREE.BufferGeometry(),
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
size: 1.3,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
const currentIncrementPoints = new THREE.Points(
|
||||
new THREE.BufferGeometry(),
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
size: 1.55,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.58,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
localSurfacePoints.visible = false;
|
||||
localSurfacePoints.frustumCulled = false;
|
||||
currentIncrementPoints.visible = false;
|
||||
currentIncrementPoints.frustumCulled = false;
|
||||
scene.add(
|
||||
staticContent,
|
||||
localSurfacePoints,
|
||||
currentIncrementPoints,
|
||||
dynamicContent,
|
||||
classifiedContent,
|
||||
);
|
||||
sceneRef.current = scene;
|
||||
cameraRef.current = camera;
|
||||
controlsRef.current = controls;
|
||||
staticContentRef.current = staticContent;
|
||||
dynamicContentRef.current = dynamicContent;
|
||||
classifiedContentRef.current = classifiedContent;
|
||||
localSurfacePointsRef.current = localSurfacePoints;
|
||||
currentIncrementPointsRef.current = currentIncrementPoints;
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
@@ -315,6 +398,10 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
controlsRef.current = null;
|
||||
staticContentRef.current = null;
|
||||
dynamicContentRef.current = null;
|
||||
classifiedContentRef.current = null;
|
||||
localSurfacePointsRef.current = null;
|
||||
currentIncrementPointsRef.current = null;
|
||||
classifiedMeshesRef.current = new Map();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -323,137 +410,71 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
if (canvas) canvas.setAttribute("aria-label", label);
|
||||
}, [label]);
|
||||
|
||||
useEffect(() => {
|
||||
const points = localSurfacePointsRef.current;
|
||||
if (!points) return;
|
||||
points.visible = showLocalSurface && localSurfaceBodyXyzM.length > 0;
|
||||
if (points.visible) updatePointPositions(points.geometry, localSurfaceBodyXyzM);
|
||||
}, [localSurfaceBodyXyzM, showLocalSurface]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const points = currentIncrementPointsRef.current;
|
||||
if (!host || !points) return;
|
||||
points.visible = showCurrentIncrement && pointCloudBodyXyzM.length > 0;
|
||||
if (!points.visible) return;
|
||||
updatePointPositions(points.geometry, pointCloudBodyXyzM);
|
||||
const material = points.material as THREE.PointsMaterial;
|
||||
const hasAlignedSemanticClasses =
|
||||
pointSemanticClassIds !== undefined
|
||||
&& pointSemanticClassIds.length === pointCloudBodyXyzM.length
|
||||
&& semanticClasses !== undefined
|
||||
&& semanticPalette !== undefined;
|
||||
if (!hasAlignedSemanticClasses) {
|
||||
if (material.vertexColors) {
|
||||
material.vertexColors = false;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
material.color.copy(tokenColor(host, "--nodedc-text-muted", [147, 151, 159]));
|
||||
return;
|
||||
}
|
||||
const declaredIds = new Set(semanticClasses.map((item) => item.id));
|
||||
const colorsByClassId = new Map<number, readonly [number, number, number]>();
|
||||
for (const entry of semanticPalette) {
|
||||
if (!declaredIds.has(entry.classId)) continue;
|
||||
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||
if (rgb) colorsByClassId.set(entry.classId, rgb);
|
||||
}
|
||||
const context = tokenColor(host, "--nodedc-text-muted", [147, 151, 159]);
|
||||
const colorAttribute = ensurePointColors(points.geometry, pointCloudBodyXyzM.length);
|
||||
const pointColors = colorAttribute.array as Float32Array;
|
||||
pointSemanticClassIds.forEach((classId, index) => {
|
||||
const rgb = classId === null ? undefined : colorsByClassId.get(classId);
|
||||
const offset = index * 3;
|
||||
pointColors[offset] = rgb ? rgb[0] / 255 : context.r;
|
||||
pointColors[offset + 1] = rgb ? rgb[1] / 255 : context.g;
|
||||
pointColors[offset + 2] = rgb ? rgb[2] / 255 : context.b;
|
||||
});
|
||||
colorAttribute.needsUpdate = true;
|
||||
if (!material.vertexColors) {
|
||||
material.vertexColors = true;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
material.color.setRGB(1, 1, 1);
|
||||
}, [
|
||||
pointCloudBodyXyzM,
|
||||
pointSemanticClassIds,
|
||||
semanticClasses,
|
||||
semanticPalette,
|
||||
showCurrentIncrement,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = dynamicContentRef.current;
|
||||
if (!host || !content) return;
|
||||
clearGroup(content);
|
||||
|
||||
if (showLocalSurface) {
|
||||
const localSurfaceGeometry = new THREE.BufferGeometry();
|
||||
localSurfaceGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(localSurfaceBodyXyzM), 3),
|
||||
);
|
||||
content.add(new THREE.Points(
|
||||
localSurfaceGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
size: 1.3,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if (showCurrentIncrement) {
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
contextGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3),
|
||||
);
|
||||
const hasAlignedSemanticClasses =
|
||||
pointSemanticClassIds !== undefined
|
||||
&& pointSemanticClassIds.length === pointCloudBodyXyzM.length
|
||||
&& semanticClasses !== undefined
|
||||
&& semanticPalette !== undefined;
|
||||
if (hasAlignedSemanticClasses) {
|
||||
const declaredIds = new Set(semanticClasses.map((item) => item.id));
|
||||
const colorsByClassId = new Map<number, readonly [number, number, number]>();
|
||||
for (const entry of semanticPalette) {
|
||||
if (!declaredIds.has(entry.classId)) continue;
|
||||
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||
if (rgb) colorsByClassId.set(entry.classId, rgb);
|
||||
}
|
||||
const context = tokenColor(host, "--nodedc-text-muted", [147, 151, 159]);
|
||||
const pointColors = new Float32Array(pointCloudBodyXyzM.length * 3);
|
||||
pointSemanticClassIds.forEach((classId, index) => {
|
||||
const rgb = classId === null ? undefined : colorsByClassId.get(classId);
|
||||
const offset = index * 3;
|
||||
pointColors[offset] = rgb ? rgb[0] / 255 : context.r;
|
||||
pointColors[offset + 1] = rgb ? rgb[1] / 255 : context.g;
|
||||
pointColors[offset + 2] = rgb ? rgb[2] / 255 : context.b;
|
||||
});
|
||||
contextGeometry.setAttribute(
|
||||
"color",
|
||||
new THREE.BufferAttribute(pointColors, 3),
|
||||
);
|
||||
}
|
||||
content.add(new THREE.Points(
|
||||
contextGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: hasAlignedSemanticClasses
|
||||
? new THREE.Color(1, 1, 1)
|
||||
: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
vertexColors: hasAlignedSemanticClasses,
|
||||
size: 1.55,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.58,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if (showClassifiedCells && classifiedCells.length) {
|
||||
const cellsByState = new Map<LaboratoryMetricCellState, LaboratoryMetricCellEvidence[]>();
|
||||
for (const cell of classifiedCells) {
|
||||
const cells = cellsByState.get(cell.state) ?? [];
|
||||
cells.push(cell);
|
||||
cellsByState.set(cell.state, cells);
|
||||
}
|
||||
for (const [state, cells] of cellsByState) {
|
||||
const color = state === "ground-support"
|
||||
? tokenColor(host, "--nodedc-success-rgb", [181, 255, 90])
|
||||
: state === "nonground-occupied"
|
||||
? tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112])
|
||||
: state === "unknown-rejected"
|
||||
? tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92])
|
||||
: tokenColor(host, "--nodedc-text-muted", [96, 99, 106]);
|
||||
const geometry = new THREE.BoxGeometry(
|
||||
classifiedCellSizeM * 0.92,
|
||||
1,
|
||||
classifiedCellSizeM * 0.92,
|
||||
);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: state === "unobserved" ? 0.035 : state === "ground-support" ? 0.12 : 0.24,
|
||||
depthWrite: false,
|
||||
});
|
||||
const mesh = new THREE.InstancedMesh(geometry, material, cells.length);
|
||||
const matrix = new THREE.Matrix4();
|
||||
const scale = new THREE.Vector3(1, 1, 1);
|
||||
const rotation = new THREE.Quaternion();
|
||||
cells.forEach((cell, index) => {
|
||||
const minimum = cell.zBoundsM[0];
|
||||
const maximum = cell.zBoundsM[1];
|
||||
const height = minimum === null || maximum === null
|
||||
? 0.018
|
||||
: Math.max(0.018, maximum - minimum);
|
||||
const centerZ = minimum === null || maximum === null
|
||||
? -0.012
|
||||
: (minimum + maximum) / 2;
|
||||
const [sceneX, sceneY, sceneZ] = scenePoint([
|
||||
cell.centerBodyXyM[0],
|
||||
cell.centerBodyXyM[1],
|
||||
centerZ,
|
||||
]);
|
||||
scale.set(1, height, 1);
|
||||
matrix.compose(
|
||||
new THREE.Vector3(sceneX, sceneY, sceneZ),
|
||||
rotation,
|
||||
scale,
|
||||
);
|
||||
mesh.setMatrixAt(index, matrix);
|
||||
});
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
content.add(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
for (const obstacle of obstacles) {
|
||||
if (
|
||||
(
|
||||
@@ -526,20 +547,97 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
}, [
|
||||
obstacles,
|
||||
occupiedVoxelSizeM,
|
||||
localSurfaceBodyXyzM,
|
||||
pointCloudBodyXyzM,
|
||||
pointSemanticClassIds,
|
||||
semanticClasses,
|
||||
semanticPalette,
|
||||
classifiedCells,
|
||||
classifiedCellSizeM,
|
||||
showCurrentIncrement,
|
||||
showClassifiedCells,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
showLowStep,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = classifiedContentRef.current;
|
||||
if (!host || !content) return;
|
||||
content.visible = showClassifiedCells && classifiedCells.length > 0;
|
||||
if (!content.visible) return;
|
||||
|
||||
const requiredCapacity = classifiedCells.length;
|
||||
const currentMeshes = classifiedMeshesRef.current;
|
||||
const firstMesh = currentMeshes.values().next().value as THREE.InstancedMesh | undefined;
|
||||
const needsAllocation = !firstMesh
|
||||
|| Number(firstMesh.userData.capacity ?? 0) < requiredCapacity
|
||||
|| Number(firstMesh.userData.cellSizeM ?? 0) !== classifiedCellSizeM;
|
||||
if (needsAllocation) {
|
||||
clearGroup(content);
|
||||
const meshes = new Map<LaboratoryMetricCellState, THREE.InstancedMesh>();
|
||||
const states: readonly LaboratoryMetricCellState[] = [
|
||||
"unobserved",
|
||||
"ground-support",
|
||||
"nonground-occupied",
|
||||
"unknown-rejected",
|
||||
];
|
||||
for (const state of states) {
|
||||
const color = state === "ground-support"
|
||||
? tokenColor(host, "--nodedc-success-rgb", [181, 255, 90])
|
||||
: state === "nonground-occupied"
|
||||
? tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112])
|
||||
: state === "unknown-rejected"
|
||||
? tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92])
|
||||
: tokenColor(host, "--nodedc-text-muted", [96, 99, 106]);
|
||||
const mesh = new THREE.InstancedMesh(
|
||||
new THREE.BoxGeometry(classifiedCellSizeM * 0.92, 1, classifiedCellSizeM * 0.92),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: state === "unobserved" ? 0.035 : state === "ground-support" ? 0.12 : 0.24,
|
||||
depthWrite: false,
|
||||
}),
|
||||
requiredCapacity,
|
||||
);
|
||||
mesh.count = 0;
|
||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
mesh.frustumCulled = false;
|
||||
mesh.userData.capacity = requiredCapacity;
|
||||
mesh.userData.cellSizeM = classifiedCellSizeM;
|
||||
meshes.set(state, mesh);
|
||||
content.add(mesh);
|
||||
}
|
||||
classifiedMeshesRef.current = meshes;
|
||||
}
|
||||
|
||||
const meshes = classifiedMeshesRef.current;
|
||||
const counts = new Map<LaboratoryMetricCellState, number>();
|
||||
const matrix = new THREE.Matrix4();
|
||||
const position = new THREE.Vector3();
|
||||
const scale = new THREE.Vector3(1, 1, 1);
|
||||
const rotation = new THREE.Quaternion();
|
||||
for (const cell of classifiedCells) {
|
||||
const mesh = meshes.get(cell.state);
|
||||
if (!mesh) continue;
|
||||
const index = counts.get(cell.state) ?? 0;
|
||||
const minimum = cell.zBoundsM[0];
|
||||
const maximum = cell.zBoundsM[1];
|
||||
const height = minimum === null || maximum === null
|
||||
? 0.018
|
||||
: Math.max(0.018, maximum - minimum);
|
||||
const centerZ = minimum === null || maximum === null
|
||||
? -0.012
|
||||
: (minimum + maximum) / 2;
|
||||
const [sceneX, sceneY, sceneZ] = scenePoint([
|
||||
cell.centerBodyXyM[0],
|
||||
cell.centerBodyXyM[1],
|
||||
centerZ,
|
||||
]);
|
||||
position.set(sceneX, sceneY, sceneZ);
|
||||
scale.set(1, height, 1);
|
||||
matrix.compose(position, rotation, scale);
|
||||
mesh.setMatrixAt(index, matrix);
|
||||
counts.set(cell.state, index + 1);
|
||||
}
|
||||
for (const [state, mesh] of meshes) {
|
||||
mesh.count = counts.get(state) ?? 0;
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
}, [classifiedCellSizeM, classifiedCells, showClassifiedCells]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = staticContentRef.current;
|
||||
|
||||
+8
-3
@@ -510,16 +510,21 @@ export function RecordedEvidenceSemanticMaskOverlay({
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const rendererRef = useRef<SemanticMaskRenderer | null | undefined>(undefined);
|
||||
const lastReadyMaskRef = useRef<{ series: string; mask: DecodedSemanticMask } | null>(null);
|
||||
const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl");
|
||||
const [mask, setMask] = useState<DecodedSemanticMask | null>(null);
|
||||
const [failure, setFailure] = useState<string | null>(null);
|
||||
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
|
||||
const prefetchSignature = prefetchSrcs.join("\n");
|
||||
const renderMask = failure
|
||||
const series = src.slice(0, Math.max(src.lastIndexOf("/"), 0));
|
||||
if (lastReadyMaskRef.current?.series !== series) lastReadyMaskRef.current = null;
|
||||
const exactMask = failure
|
||||
? null
|
||||
: mask?.key === expectedKey
|
||||
? mask
|
||||
: decodedMaskCache.get(expectedKey) ?? null;
|
||||
if (exactMask) lastReadyMaskRef.current = { series, mask: exactMask };
|
||||
const renderMask = exactMask ?? lastReadyMaskRef.current?.mask ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setFailure(null);
|
||||
@@ -627,8 +632,8 @@ export function RecordedEvidenceSemanticMaskOverlay({
|
||||
className="recorded-evidence-semantic-mask-overlay"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={!failure && !renderMask}
|
||||
data-state={failure ? "error" : renderMask ? "ready" : "loading"}
|
||||
aria-busy={!failure && !exactMask}
|
||||
data-state={failure ? "error" : exactMask ? "ready" : renderMask ? "stale" : "loading"}
|
||||
data-renderer={rendererMode}
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user