feat(perception): add E34 temporal occupied layer
This commit is contained in:
@@ -93,6 +93,7 @@ export interface AdvancedLaboratoryResults {
|
||||
e31: E31LaboratoryResult | null;
|
||||
e32: E32LaboratoryResult | null;
|
||||
e33: E33LaboratoryResult | null;
|
||||
e34: E34TemporalLayerResult | null;
|
||||
}
|
||||
|
||||
export class AdvancedLaboratoryContractError extends Error {
|
||||
@@ -371,10 +372,15 @@ export async function fetchAdvancedLaboratoryResults({
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<AdvancedLaboratoryResults> {
|
||||
const [e31, e32, e33] = await Promise.all([
|
||||
const [e31, e32, e33, e34] = await Promise.all([
|
||||
fetchOne("/api/v1/laboratory/e31/results?limit=1", parseE31, fetcher, signal),
|
||||
fetchOne("/api/v1/laboratory/e32/results?limit=1", parseE32, fetcher, signal),
|
||||
fetchOne("/api/v1/laboratory/e33/results?limit=1", parseE33, fetcher, signal),
|
||||
fetchE34TemporalLayerResult({ fetcher, signal }),
|
||||
]);
|
||||
return { e31, e32, e33 };
|
||||
return { e31, e32, e33, e34 };
|
||||
}
|
||||
import {
|
||||
fetchE34TemporalLayerResult,
|
||||
type E34TemporalLayerResult,
|
||||
} from "./e34TemporalLayer";
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
export type E34TemporalState = "current" | "held" | "expired";
|
||||
export type E34OccupancyState = "occupied" | "unknown";
|
||||
export type E34OwnerKind = "camera-track" | "geometry-cluster";
|
||||
export type E34Point3 = readonly [number, number, number];
|
||||
|
||||
export interface E34TemporalHistoryPoint {
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
centroidMapXyzM: E34Point3;
|
||||
}
|
||||
|
||||
export interface E34TemporalComponent {
|
||||
temporalId: number;
|
||||
state: E34TemporalState;
|
||||
occupancyState: E34OccupancyState;
|
||||
ownerKind: E34OwnerKind;
|
||||
centroidMapXyzM: E34Point3;
|
||||
lastObservedAgeSeconds: number;
|
||||
associationReason: string;
|
||||
history: readonly E34TemporalHistoryPoint[];
|
||||
}
|
||||
|
||||
export interface E34TemporalReviewFrame {
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
sourceAvailable: boolean;
|
||||
layerState: "current" | "held" | "unknown";
|
||||
counts: {
|
||||
current: number;
|
||||
held: number;
|
||||
expired: number;
|
||||
};
|
||||
cellCentersMapXyzM: readonly E34Point3[];
|
||||
components: readonly E34TemporalComponent[];
|
||||
}
|
||||
|
||||
export interface E34TemporalLayerResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
status: "accepted-bounded-occupied-unknown-temporal-layer";
|
||||
e32ResultId: string;
|
||||
e33ResultId: string;
|
||||
profileId: string;
|
||||
pipelineId: string;
|
||||
coordinateFrame: "map";
|
||||
configuration: {
|
||||
voxelSizeM: number;
|
||||
occupiedTtlSeconds: number;
|
||||
maximumActiveComponents: number;
|
||||
maximumCellsPerComponent: number;
|
||||
geometryMaximumGapSeconds: number;
|
||||
geometryMaximumCentroidDistanceM: number;
|
||||
};
|
||||
metrics: {
|
||||
sourceFrames: number;
|
||||
processedFrames: number;
|
||||
framesWithCurrentLayer: number;
|
||||
heldOnlyFrames: number;
|
||||
unknownEmptyFrames: number;
|
||||
createdComponents: number;
|
||||
cameraCreatedComponents: number;
|
||||
geometryCreatedComponents: number;
|
||||
geometryReassociatedComponents: number;
|
||||
exactCameraAssociations: number;
|
||||
geometrySpatialReassociations: number;
|
||||
heldPublications: number;
|
||||
expiredComponents: number;
|
||||
peakActiveComponents: number;
|
||||
maximumObservedSpanSeconds: number;
|
||||
continuityFraction: number;
|
||||
geometryOnlyReassociationFraction: number;
|
||||
e32CurrentPointRows: number;
|
||||
e34ConsumedCurrentPointRows: number;
|
||||
currentCellRows: number;
|
||||
heldCellRows: number;
|
||||
storedCellRows: number;
|
||||
freeCellRows: number;
|
||||
peakCellsPerComponent: number;
|
||||
maximumHeldAgeSeconds: number;
|
||||
maximumExpiryDelaySeconds: number;
|
||||
maximumReplayMaterializationDelaySeconds: number;
|
||||
mapFrameJumpCandidates: number;
|
||||
frameProcessingP95Ms: number;
|
||||
buildElapsedMs: number;
|
||||
};
|
||||
reviewFrames: readonly E34TemporalReviewFrame[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
class E34TemporalLayerContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E34TemporalLayerContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function list(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
const parsed = finiteNumber(value, label);
|
||||
if (parsed < 0) {
|
||||
throw new E34TemporalLayerContractError(`${label}: число отрицательно.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactString<T extends string>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new E34TemporalLayerContractError(`${label}: неверное значение.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(
|
||||
value: unknown,
|
||||
expected: readonly T[],
|
||||
label: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !expected.includes(value as T)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: неверное значение.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: неверный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function point(value: unknown, label: string): E34Point3 {
|
||||
const coordinates = list(value, label);
|
||||
if (coordinates.length !== 3) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидались XYZ.`);
|
||||
}
|
||||
return [
|
||||
finiteNumber(coordinates[0], `${label}[0]`),
|
||||
finiteNumber(coordinates[1], `${label}[1]`),
|
||||
finiteNumber(coordinates[2], `${label}[2]`),
|
||||
];
|
||||
}
|
||||
|
||||
function parseHistory(value: unknown, label: string): E34TemporalHistoryPoint {
|
||||
const item = record(value, label);
|
||||
return {
|
||||
frameIndex: integerValue(item.frame_index, `${label}.frame_index`),
|
||||
sessionSeconds: numberValue(
|
||||
item.session_seconds,
|
||||
`${label}.session_seconds`,
|
||||
),
|
||||
centroidMapXyzM: point(
|
||||
item.centroid_map_xyz_m,
|
||||
`${label}.centroid_map_xyz_m`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseComponent(value: unknown, label: string): E34TemporalComponent {
|
||||
const item = record(value, label);
|
||||
return {
|
||||
temporalId: integerValue(item.temporal_id, `${label}.temporal_id`),
|
||||
state: oneOf(
|
||||
item.state,
|
||||
["current", "held", "expired"] as const,
|
||||
`${label}.state`,
|
||||
),
|
||||
occupancyState: oneOf(
|
||||
item.occupancy_state,
|
||||
["occupied", "unknown"] as const,
|
||||
`${label}.occupancy_state`,
|
||||
),
|
||||
ownerKind: oneOf(
|
||||
item.owner_kind,
|
||||
["camera-track", "geometry-cluster"] as const,
|
||||
`${label}.owner_kind`,
|
||||
),
|
||||
centroidMapXyzM: point(
|
||||
item.centroid_map_xyz_m,
|
||||
`${label}.centroid_map_xyz_m`,
|
||||
),
|
||||
lastObservedAgeSeconds: numberValue(
|
||||
item.last_observed_age_seconds,
|
||||
`${label}.last_observed_age_seconds`,
|
||||
),
|
||||
associationReason: stringValue(
|
||||
item.association_reason,
|
||||
`${label}.association_reason`,
|
||||
),
|
||||
history: list(item.history_tail, `${label}.history_tail`).map(
|
||||
(entry, index) => parseHistory(entry, `${label}.history_tail[${index}]`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReviewFrame(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E34TemporalReviewFrame {
|
||||
const item = record(value, label);
|
||||
const counts = record(item.counts, `${label}.counts`);
|
||||
return {
|
||||
frameIndex: integerValue(item.frame_index, `${label}.frame_index`),
|
||||
sessionSeconds: numberValue(
|
||||
item.session_seconds,
|
||||
`${label}.session_seconds`,
|
||||
),
|
||||
sourceAvailable: booleanValue(
|
||||
item.source_available,
|
||||
`${label}.source_available`,
|
||||
),
|
||||
layerState: oneOf(
|
||||
item.layer_state,
|
||||
["current", "held", "unknown"] as const,
|
||||
`${label}.layer_state`,
|
||||
),
|
||||
counts: {
|
||||
current: integerValue(counts.current, `${label}.counts.current`),
|
||||
held: integerValue(counts.held, `${label}.counts.held`),
|
||||
expired: integerValue(counts.expired, `${label}.counts.expired`),
|
||||
},
|
||||
cellCentersMapXyzM: list(
|
||||
item.cell_centers_map_xyz_m,
|
||||
`${label}.cell_centers_map_xyz_m`,
|
||||
).map((entry, index) => point(
|
||||
entry,
|
||||
`${label}.cell_centers_map_xyz_m[${index}]`,
|
||||
)),
|
||||
components: list(item.components, `${label}.components`).map(
|
||||
(entry, index) => parseComponent(
|
||||
entry,
|
||||
`${label}.components[${index}]`,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticAuthority(value: unknown, label: string): void {
|
||||
const authority = record(value, label);
|
||||
if (
|
||||
authority.commands_enabled !== false
|
||||
|| authority.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new E34TemporalLayerContractError(
|
||||
`${label}: запрещённые полномочия.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function metricRecord(
|
||||
value: Record<string, unknown>,
|
||||
): E34TemporalLayerResult["metrics"] {
|
||||
const metric = (key: string) => numberValue(
|
||||
value[key],
|
||||
`E34.metrics.${key}`,
|
||||
);
|
||||
const integer = (key: string) => integerValue(
|
||||
value[key],
|
||||
`E34.metrics.${key}`,
|
||||
);
|
||||
return {
|
||||
sourceFrames: integer("source_frames"),
|
||||
processedFrames: integer("processed_frames"),
|
||||
framesWithCurrentLayer: integer("frames_with_current_layer"),
|
||||
heldOnlyFrames: integer("held_only_frames"),
|
||||
unknownEmptyFrames: integer("unknown_empty_frames"),
|
||||
createdComponents: integer("created_components"),
|
||||
cameraCreatedComponents: integer("camera_created_components"),
|
||||
geometryCreatedComponents: integer("geometry_created_components"),
|
||||
geometryReassociatedComponents: integer("geometry_reassociated_components"),
|
||||
exactCameraAssociations: integer("exact_camera_associations"),
|
||||
geometrySpatialReassociations: integer("geometry_spatial_reassociations"),
|
||||
heldPublications: integer("held_publications"),
|
||||
expiredComponents: integer("expired_components"),
|
||||
peakActiveComponents: integer("peak_active_components"),
|
||||
maximumObservedSpanSeconds: metric("maximum_observed_span_seconds"),
|
||||
continuityFraction: metric("continuity_fraction"),
|
||||
geometryOnlyReassociationFraction: metric(
|
||||
"geometry_only_reassociation_fraction",
|
||||
),
|
||||
e32CurrentPointRows: integer("e32_current_point_rows"),
|
||||
e34ConsumedCurrentPointRows: integer("e34_consumed_current_point_rows"),
|
||||
currentCellRows: integer("current_cell_rows"),
|
||||
heldCellRows: integer("held_cell_rows"),
|
||||
storedCellRows: integer("stored_cell_rows"),
|
||||
freeCellRows: integer("free_cell_rows"),
|
||||
peakCellsPerComponent: integer("peak_cells_per_component"),
|
||||
maximumHeldAgeSeconds: metric("maximum_held_age_seconds"),
|
||||
maximumExpiryDelaySeconds: metric("maximum_expiry_delay_seconds"),
|
||||
maximumReplayMaterializationDelaySeconds: metric(
|
||||
"maximum_replay_materialization_delay_seconds",
|
||||
),
|
||||
mapFrameJumpCandidates: integer("map_frame_jump_candidates"),
|
||||
frameProcessingP95Ms: metric("frame_processing_p95_ms"),
|
||||
buildElapsedMs: metric("build_elapsed_ms"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E34TemporalLayerResult {
|
||||
const item = record(value, "E34");
|
||||
const configuration = record(item.configuration, "E34.configuration");
|
||||
const review = record(item.review, "E34.review");
|
||||
const acceptance = record(item.acceptance, "E34.acceptance");
|
||||
diagnosticAuthority(item.authority, "E34.authority");
|
||||
if (acceptance.accepted !== true) {
|
||||
throw new E34TemporalLayerContractError("E34.acceptance: результат отклонён.");
|
||||
}
|
||||
exactString(
|
||||
review.schema_version,
|
||||
"missioncore.e34-temporal-review-timeline/v1",
|
||||
"E34.review.schema_version",
|
||||
);
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e34-temporal-occupied",
|
||||
"E34.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E34.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E34.source_session_id",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-bounded-occupied-unknown-temporal-layer",
|
||||
"E34.status",
|
||||
),
|
||||
e32ResultId: contentId(
|
||||
item.e32_result_id,
|
||||
"e32-track-geometry",
|
||||
"E34.e32_result_id",
|
||||
),
|
||||
e33ResultId: contentId(
|
||||
item.e33_result_id,
|
||||
"e33-worker-shadow",
|
||||
"E34.e33_result_id",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E34.profile_id"),
|
||||
pipelineId: stringValue(item.pipeline_id, "E34.pipeline_id"),
|
||||
coordinateFrame: exactString(
|
||||
item.coordinate_frame,
|
||||
"map",
|
||||
"E34.coordinate_frame",
|
||||
),
|
||||
configuration: {
|
||||
voxelSizeM: numberValue(
|
||||
configuration.voxel_size_m,
|
||||
"E34.configuration.voxel_size_m",
|
||||
),
|
||||
occupiedTtlSeconds: numberValue(
|
||||
configuration.occupied_ttl_seconds,
|
||||
"E34.configuration.occupied_ttl_seconds",
|
||||
),
|
||||
maximumActiveComponents: integerValue(
|
||||
configuration.maximum_active_components,
|
||||
"E34.configuration.maximum_active_components",
|
||||
),
|
||||
maximumCellsPerComponent: integerValue(
|
||||
configuration.maximum_cells_per_component,
|
||||
"E34.configuration.maximum_cells_per_component",
|
||||
),
|
||||
geometryMaximumGapSeconds: numberValue(
|
||||
configuration.geometry_maximum_gap_seconds,
|
||||
"E34.configuration.geometry_maximum_gap_seconds",
|
||||
),
|
||||
geometryMaximumCentroidDistanceM: numberValue(
|
||||
configuration.geometry_maximum_centroid_distance_m,
|
||||
"E34.configuration.geometry_maximum_centroid_distance_m",
|
||||
),
|
||||
},
|
||||
metrics: metricRecord(record(item.metrics, "E34.metrics")),
|
||||
reviewFrames: list(review.frames, "E34.review.frames").map(
|
||||
(entry, index) => parseReviewFrame(
|
||||
entry,
|
||||
`E34.review.frames[${index}]`,
|
||||
),
|
||||
),
|
||||
access: exactString(item.access, "read-only", "E34.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE34TemporalLayerResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E34TemporalLayerResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/e34/results?limit=1",
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E34TemporalLayerContractError(
|
||||
`Каталог LAB E34 недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const catalog = record(await response.json(), "Каталог LAB E34");
|
||||
exactString(
|
||||
catalog.schema_version,
|
||||
"missioncore.laboratory-advanced-catalog/v1",
|
||||
"Каталог LAB E34.schema_version",
|
||||
);
|
||||
booleanValue(catalog.configured, "Каталог LAB E34.configured");
|
||||
integerValue(catalog.candidate_total, "Каталог LAB E34.candidate_total");
|
||||
integerValue(catalog.invalid_total, "Каталог LAB E34.invalid_total");
|
||||
exactString(
|
||||
catalog.access,
|
||||
"read-only",
|
||||
"Каталог LAB E34.access",
|
||||
);
|
||||
const items = list(catalog.items, "Каталог LAB E34.items");
|
||||
if (items.length > 1) {
|
||||
throw new E34TemporalLayerContractError(
|
||||
"Каталог LAB E34: нарушен limit=1.",
|
||||
);
|
||||
}
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
@import "./styles/workspaces.css";
|
||||
@import "./styles/laboratory.css";
|
||||
@import "./styles/laboratory-reporting.css";
|
||||
@import "./styles/e34-temporal-layer.css";
|
||||
@import "./styles/e30-human-review.css";
|
||||
@import "./styles/spatial.css";
|
||||
@import "./styles/device.css";
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
.e34-temporal-evidence,
|
||||
.e34-temporal-scene {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.e34-temporal-scene {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.e34-temporal-scene__viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.e34-temporal-scene__viewport canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.e34-temporal-scene__viewport canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.e34-temporal-scene__toolbar {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 0.6rem;
|
||||
left: 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.e34-temporal-scene__toolbar .nodedc-button {
|
||||
background: var(--nodedc-floating-surface);
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.e34-temporal-scene__gestures,
|
||||
.e34-temporal-scene__legend,
|
||||
.e34-temporal-evidence__telemetry {
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
color: var(--nodedc-text-secondary);
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.e34-temporal-scene__gestures {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
padding: 0.43rem 0.55rem;
|
||||
}
|
||||
|
||||
.e34-temporal-scene__gestures span,
|
||||
.e34-temporal-scene__legend span,
|
||||
.e34-temporal-evidence__telemetry dt,
|
||||
.e34-temporal-evidence__telemetry dd {
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.e34-temporal-scene__legend {
|
||||
position: absolute;
|
||||
right: 0.6rem;
|
||||
bottom: 0.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
padding: 0.42rem 0.55rem;
|
||||
}
|
||||
|
||||
.e34-temporal-scene__legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.e34-temporal-scene__legend span::before {
|
||||
width: 0.38rem;
|
||||
height: 0.38rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.e34-temporal-scene__legend span[data-state="current"]::before {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
}
|
||||
|
||||
.e34-temporal-scene__legend span[data-state="held"]::before {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
.e34-temporal-scene__legend span[data-state="expired"]::before {
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.e34-temporal-evidence__telemetry {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 0.6rem;
|
||||
bottom: 0.6rem;
|
||||
display: grid;
|
||||
width: min(13rem, calc(100% - 1.2rem));
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.65rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.e34-temporal-evidence__telemetry > div {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.e34-temporal-evidence__telemetry dt,
|
||||
.e34-temporal-evidence__telemetry dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.e34-temporal-evidence__telemetry dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.e34-temporal-evidence__telemetry dd {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e34-temporal-evidence .laboratory-evidence-viewer__controls
|
||||
.nodedc-select-anchor {
|
||||
width: clamp(12rem, 24vw, 20rem);
|
||||
}
|
||||
|
||||
.e34-temporal-scene__error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.e34-temporal-scene__gestures {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.e34-temporal-scene__legend {
|
||||
left: 0.6rem;
|
||||
right: auto;
|
||||
bottom: 5.7rem;
|
||||
}
|
||||
|
||||
.e34-temporal-evidence .laboratory-evidence-viewer__controls
|
||||
.nodedc-select-anchor {
|
||||
width: 10rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import type { LaboratoryOption } from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { AdvancedLaboratoryResults } from "../../core/laboratory/advancedResults";
|
||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||
import type { WorkspaceRendererProps } from "../contracts";
|
||||
import { E31Result } from "./E31Result";
|
||||
import { E32Result } from "./E32Result";
|
||||
import { E33Result } from "./E33Result";
|
||||
import { E34Result } from "./E34Result";
|
||||
import { RecordedReplayEvidence } from "./RecordedReplayEvidence";
|
||||
|
||||
export type AdvancedLaboratoryWorkId =
|
||||
| "e31-source-binding"
|
||||
| "e32-track-geometry"
|
||||
| "e33-worker-shadow"
|
||||
| "e34-temporal-layer";
|
||||
|
||||
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||
};
|
||||
|
||||
export function isAdvancedLaboratoryWorkId(
|
||||
value: string,
|
||||
): value is AdvancedLaboratoryWorkId {
|
||||
return (
|
||||
value === "e31-source-binding"
|
||||
|| value === "e32-track-geometry"
|
||||
|| value === "e33-worker-shadow"
|
||||
|| value === "e34-temporal-layer"
|
||||
);
|
||||
}
|
||||
|
||||
export function advancedLaboratoryWorkOptions(
|
||||
results: AdvancedLaboratoryResults,
|
||||
sourceSessions: ReadonlyMap<string, ObservationSessionSummary>,
|
||||
): readonly LaboratoryOption<AdvancedLaboratoryWorkId>[] {
|
||||
const options: LaboratoryOption<AdvancedLaboratoryWorkId>[] = [];
|
||||
if (results.e31 && sourceSessions.has(results.e31.sourceSessionId)) {
|
||||
options.push({
|
||||
id: "e31-source-binding",
|
||||
label: "LAB E31 · source binding",
|
||||
});
|
||||
}
|
||||
if (results.e32 && sourceSessions.has(results.e32.sourceSessionId)) {
|
||||
options.push({
|
||||
id: "e32-track-geometry",
|
||||
label: "LAB E32 · TrackGeometry v1",
|
||||
});
|
||||
}
|
||||
if (results.e33 && sourceSessions.has(results.e33.sourceSessionId)) {
|
||||
options.push({
|
||||
id: "e33-worker-shadow",
|
||||
label: "LAB E33 · worker shadow 1×",
|
||||
});
|
||||
}
|
||||
if (results.e34) {
|
||||
options.push({
|
||||
id: "e34-temporal-layer",
|
||||
label: "LAB E34 · temporal occupied/unknown",
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export function advancedLaboratorySourceSession(
|
||||
workId: AdvancedLaboratoryWorkId,
|
||||
results: AdvancedLaboratoryResults,
|
||||
sourceSessions: ReadonlyMap<string, ObservationSessionSummary>,
|
||||
): ObservationSessionSummary | null {
|
||||
const sourceSessionId = workId === "e31-source-binding"
|
||||
? results.e31?.sourceSessionId
|
||||
: workId === "e32-track-geometry"
|
||||
? results.e32?.sourceSessionId
|
||||
: workId === "e33-worker-shadow"
|
||||
? results.e33?.sourceSessionId
|
||||
: null;
|
||||
return sourceSessionId ? sourceSessions.get(sourceSessionId) ?? null : null;
|
||||
}
|
||||
|
||||
export function AdvancedLaboratoryResult({
|
||||
props,
|
||||
rigLabel,
|
||||
workId,
|
||||
results,
|
||||
sourceSessions,
|
||||
replayingSessionId,
|
||||
failedSessionId,
|
||||
replayError,
|
||||
}: {
|
||||
props: LaboratoryWorkspaceProps;
|
||||
rigLabel: string;
|
||||
workId: AdvancedLaboratoryWorkId;
|
||||
results: AdvancedLaboratoryResults;
|
||||
sourceSessions: ReadonlyMap<string, ObservationSessionSummary>;
|
||||
replayingSessionId: string | null;
|
||||
failedSessionId: string | null;
|
||||
replayError: string | null;
|
||||
}) {
|
||||
if (workId === "e34-temporal-layer" && results.e34) {
|
||||
return <E34Result rigLabel={rigLabel} result={results.e34} />;
|
||||
}
|
||||
|
||||
const sourceSession = advancedLaboratorySourceSession(
|
||||
workId,
|
||||
results,
|
||||
sourceSessions,
|
||||
);
|
||||
if (!sourceSession) return null;
|
||||
const evidence = (
|
||||
<RecordedReplayEvidence
|
||||
props={props}
|
||||
sourceSession={sourceSession}
|
||||
loading={replayingSessionId === sourceSession.id}
|
||||
error={failedSessionId === sourceSession.id ? replayError : null}
|
||||
/>
|
||||
);
|
||||
if (workId === "e31-source-binding" && results.e31) {
|
||||
return (
|
||||
<E31Result
|
||||
rigLabel={rigLabel}
|
||||
result={results.e31}
|
||||
evidence={evidence}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (workId === "e32-track-geometry" && results.e32) {
|
||||
return (
|
||||
<E32Result
|
||||
rigLabel={rigLabel}
|
||||
result={results.e32}
|
||||
evidence={evidence}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (workId === "e33-worker-shadow" && results.e33) {
|
||||
return (
|
||||
<E33Result
|
||||
rigLabel={rigLabel}
|
||||
result={results.e33}
|
||||
evidence={evidence}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Select } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import type {
|
||||
E34TemporalLayerResult,
|
||||
E34TemporalReviewFrame,
|
||||
} from "../../core/laboratory/e34TemporalLayer";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import {
|
||||
E34TemporalLayerScene,
|
||||
type E34TemporalViewMode,
|
||||
} from "./E34TemporalLayerScene";
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})}%`;
|
||||
}
|
||||
|
||||
function defaultFrame(
|
||||
frames: readonly E34TemporalReviewFrame[],
|
||||
): E34TemporalReviewFrame | null {
|
||||
return frames.find((frame) => (
|
||||
frame.counts.current > 0 && frame.counts.held > 0
|
||||
)) ?? frames.find((frame) => frame.counts.expired > 0) ?? frames[0] ?? null;
|
||||
}
|
||||
|
||||
function frameLabel(frame: E34TemporalReviewFrame): string {
|
||||
const state = frame.counts.expired
|
||||
? `истекло ${frame.counts.expired}`
|
||||
: frame.counts.held
|
||||
? `удерживается ${frame.counts.held}`
|
||||
: `наблюдается ${frame.counts.current}`;
|
||||
return `Кадр ${formatNumber(frame.frameIndex, 0)} · ${state}`;
|
||||
}
|
||||
|
||||
function E34Evidence({
|
||||
result,
|
||||
}: {
|
||||
result: E34TemporalLayerResult;
|
||||
}) {
|
||||
const initial = useMemo(
|
||||
() => defaultFrame(result.reviewFrames),
|
||||
[result.reviewFrames],
|
||||
);
|
||||
const [frameIndex, setFrameIndex] = useState(initial?.frameIndex ?? 0);
|
||||
const [mode, setMode] = useState<E34TemporalViewMode>("3d");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const frame = result.reviewFrames.find(
|
||||
(item) => item.frameIndex === frameIndex,
|
||||
) ?? initial;
|
||||
|
||||
if (!frame) {
|
||||
return (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
Контрольные состояния временного слоя не опубликованы.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="e34-temporal-evidence">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="Временной occupied/unknown слой E34"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "План" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={(
|
||||
<Select
|
||||
label="Контрольное состояние E34"
|
||||
value={String(frame.frameIndex)}
|
||||
options={result.reviewFrames.map((item) => ({
|
||||
value: String(item.frameIndex),
|
||||
label: frameLabel(item),
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => setFrameIndex(Number(value))}
|
||||
/>
|
||||
)}
|
||||
overlay={(
|
||||
<dl className="e34-temporal-evidence__telemetry">
|
||||
<div>
|
||||
<dt>Кадр / время</dt>
|
||||
<dd>
|
||||
{formatNumber(frame.frameIndex, 0)}
|
||||
{" · "}
|
||||
{frame.sessionSeconds.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 3,
|
||||
})}
|
||||
{" с"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Вход</dt>
|
||||
<dd>{frame.sourceAvailable ? "Есть текущие точки" : "Текущих точек нет"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Состояние слоя</dt>
|
||||
<dd>
|
||||
{frame.counts.current} current · {frame.counts.held} held · {frame.counts.expired} expired
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
>
|
||||
<E34TemporalLayerScene frame={frame} mode={mode} />
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function E34Result({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E34TemporalLayerResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
const config = result.configuration;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E34 · короткоживущий occupied/unknown слой"
|
||||
description="Проверяли, может ли TrackGeometry поддерживать ограниченную во времени карту занятости: подтверждённые точки публикуются как occupied, исчезнувшее наблюдение кратко удерживается как unknown и затем удаляется строго по TTL."
|
||||
status="Временной слой принят"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Источник", value: `${rigLabel} · TrackGeometry E32` },
|
||||
{
|
||||
label: "Объём проверки",
|
||||
value: `${formatNumber(metrics.processedFrames, 0)} / ${formatNumber(metrics.sourceFrames, 0)} кадров`,
|
||||
},
|
||||
{
|
||||
label: "Параметры слоя",
|
||||
value: `${config.voxelSizeM.toLocaleString("ru-RU")} м · TTL ${config.occupiedTtlSeconds.toLocaleString("ru-RU")} с`,
|
||||
},
|
||||
{ label: "Полномочия", value: "Диагностика · команды и safety выключены" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Можно ли сохранить краткую пространственную непрерывность объектов между наблюдениями, не объявляя ненаблюдаемое пространство свободным и не загрязняя постоянную карту?",
|
||||
approach: "Полная запись E32 воспроизведена через hit-only voxel layer в map-frame. Camera-track связывались только по точной идентичности, geometry-only компоненты — по ограниченному пространственно-временному сопоставлению. Первый immutable replay был отклонён; после исправления логических часов TTL и детектора глобального сдвига тот же замороженный профиль выполнен повторно.",
|
||||
principalResult: `Да, в диагностическом контуре. Обработаны все ${formatNumber(metrics.processedFrames, 0)} кадров и все ${formatNumber(metrics.e34ConsumedCurrentPointRows, 0)} текущих точек; непрерывность наблюдений составила ${percent(metrics.continuityFraction)}, задержка логического истечения — ${metrics.maximumExpiryDelaySeconds.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с.`,
|
||||
limitation: "Слой не вычисляет свободное пространство, не назначает dynamic/static, не меняет persistent reconstruction и не имеет навигационных или safety-полномочий. Непрерывность — диагностическая метрика, не ground truth.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: "TrackGeometry → short-TTL hit-only occupied/unknown layer",
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: "Принятый TrackGeometry из LAB E32",
|
||||
version: `${formatNumber(metrics.e32CurrentPointRows, 0)} квалифицированных точек`,
|
||||
role: "неизменяемый map-frame вход с явной принадлежностью точек",
|
||||
identitySha256: null,
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: "Bounded temporal component association",
|
||||
version: `voxel ${config.voxelSizeM} м · TTL ${config.occupiedTtlSeconds} с`,
|
||||
role: "точное camera-связывание, geometry-only reassociation, hold и независимое expiry",
|
||||
identitySha256: null,
|
||||
},
|
||||
{
|
||||
kind: "runtime",
|
||||
name: "Fail-closed diagnostic replay",
|
||||
version: `${metrics.frameProcessingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс/frame p95`,
|
||||
role: "полный учёт кадров, bounds, контроль map-frame и неизменность upstream",
|
||||
identitySha256: null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="ВРЕМЕННОЕ ДОКАЗАТЕЛЬСТВО"
|
||||
title="Контрольные состояния: наблюдение, удержание unknown и точное истечение"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<E34Evidence result={result} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Краткая непрерывность доказана; свободное пространство намеренно не выводится"
|
||||
status="12 / 12 gate"
|
||||
statusTone="success"
|
||||
metrics={[
|
||||
{
|
||||
label: "Полный replay",
|
||||
value: `${formatNumber(metrics.processedFrames, 0)} / ${formatNumber(metrics.sourceFrames, 0)}`,
|
||||
hint: "все входные точки учтены",
|
||||
},
|
||||
{
|
||||
label: "Связность компонентов",
|
||||
value: percent(metrics.continuityFraction),
|
||||
hint: `${formatNumber(metrics.exactCameraAssociations + metrics.geometrySpatialReassociations, 0)} повторных связей`,
|
||||
},
|
||||
{
|
||||
label: "TTL / истечение",
|
||||
value: `${metrics.maximumHeldAgeSeconds.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} с`,
|
||||
hint: `${metrics.maximumExpiryDelaySeconds.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с задержка`,
|
||||
},
|
||||
{
|
||||
label: "Ограниченность",
|
||||
value: `${formatNumber(metrics.peakActiveComponents, 0)} / ${formatNumber(config.maximumActiveComponents, 0)}`,
|
||||
hint: `${formatNumber(metrics.peakCellsPerComponent, 0)} / ${formatNumber(config.maximumCellsPerComponent, 0)} ячеек`,
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "На данной полной записи hit-backed компоненты можно детерминированно удерживать до 0,75 с и удалять по независимому deadline, сохраняя bounds, полный учёт и неизменность E32/E33.",
|
||||
notProved: "Работа не доказывает свободное пространство, динамический класс, качество планирования, переносимость порогов на другой сенсорный риг или корректность при реальном глобальном скачке map-frame.",
|
||||
decision: "E34 принимается как read-only диагностический слой. Следующий критический gate — E35: детерминированно проверить деградацию и восстановление при потере источника, нарушении сроков и map-frame fault.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
|
||||
import type {
|
||||
E34Point3,
|
||||
E34TemporalComponent,
|
||||
E34TemporalReviewFrame,
|
||||
} from "../../core/laboratory/e34TemporalLayer";
|
||||
|
||||
export type E34TemporalViewMode = "3d" | "plan";
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
): THREE.Color {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
if (value.startsWith("#")) {
|
||||
return new THREE.Color(value);
|
||||
}
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3
|
||||
? channels
|
||||
: fallback;
|
||||
return new THREE.Color(red / 255, green / 255, blue / 255);
|
||||
}
|
||||
|
||||
function disposeRenderable(object: THREE.Object3D): void {
|
||||
const renderable = object as THREE.Object3D & {
|
||||
geometry?: THREE.BufferGeometry;
|
||||
material?: THREE.Material | THREE.Material[];
|
||||
};
|
||||
renderable.geometry?.dispose();
|
||||
const materials = Array.isArray(renderable.material)
|
||||
? renderable.material
|
||||
: renderable.material
|
||||
? [renderable.material]
|
||||
: [];
|
||||
materials.forEach((material) => material.dispose());
|
||||
}
|
||||
|
||||
function scenePoint(
|
||||
point: E34Point3,
|
||||
origin: E34Point3,
|
||||
): readonly [number, number, number] {
|
||||
return [
|
||||
point[0] - origin[0],
|
||||
point[2] - origin[2],
|
||||
-(point[1] - origin[1]),
|
||||
];
|
||||
}
|
||||
|
||||
function positions(
|
||||
points: readonly E34Point3[],
|
||||
origin: E34Point3,
|
||||
): Float32Array {
|
||||
const result = new Float32Array(points.length * 3);
|
||||
points.forEach((point, index) => {
|
||||
const [x, y, z] = scenePoint(point, origin);
|
||||
const offset = index * 3;
|
||||
result[offset] = x;
|
||||
result[offset + 1] = y;
|
||||
result[offset + 2] = z;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function median(values: readonly number[]): number {
|
||||
if (!values.length) return 0;
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
return sorted[Math.floor(sorted.length / 2)] ?? 0;
|
||||
}
|
||||
|
||||
function frameOrigin(frame: E34TemporalReviewFrame): E34Point3 {
|
||||
const anchors = frame.components.length
|
||||
? frame.components.map((component) => component.centroidMapXyzM)
|
||||
: frame.cellCentersMapXyzM;
|
||||
return [
|
||||
median(anchors.map((point) => point[0])),
|
||||
median(anchors.map((point) => point[1])),
|
||||
median(anchors.map((point) => point[2])),
|
||||
];
|
||||
}
|
||||
|
||||
function componentColor(
|
||||
host: HTMLElement,
|
||||
component: E34TemporalComponent,
|
||||
): THREE.Color {
|
||||
if (component.state === "expired") {
|
||||
return tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112]);
|
||||
}
|
||||
if (component.state === "held") {
|
||||
return tokenColor(host, "--nodedc-warning-rgb", [255, 209, 102]);
|
||||
}
|
||||
return tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]);
|
||||
}
|
||||
|
||||
function fitRadius(frame: E34TemporalReviewFrame, origin: E34Point3): number {
|
||||
const points = [
|
||||
...frame.cellCentersMapXyzM,
|
||||
...frame.components.map((component) => component.centroidMapXyzM),
|
||||
];
|
||||
if (!points.length) return 4;
|
||||
const distances = points
|
||||
.map((point) => {
|
||||
const [x, y, z] = scenePoint(point, origin);
|
||||
return Math.hypot(x, y, z);
|
||||
})
|
||||
.sort((left, right) => left - right);
|
||||
const p95 = distances[Math.floor((distances.length - 1) * 0.95)] ?? 4;
|
||||
return THREE.MathUtils.clamp(p95, 3, 32);
|
||||
}
|
||||
|
||||
export function E34TemporalLayerScene({
|
||||
frame,
|
||||
mode,
|
||||
}: {
|
||||
frame: E34TemporalReviewFrame;
|
||||
mode: E34TemporalViewMode;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const contentRef = useRef<THREE.Group | null>(null);
|
||||
const viewRadiusRef = useRef(5);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
const origin = useMemo(() => frameOrigin(frame), [frame]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
setRenderError("Браузер не смог создать 3D-сцену временного слоя.");
|
||||
return;
|
||||
}
|
||||
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(
|
||||
tokenColor(host, "--nodedc-canvas", [5, 5, 6]),
|
||||
1,
|
||||
);
|
||||
renderer.domElement.setAttribute(
|
||||
"aria-label",
|
||||
"Интерактивная 3D-сцена временного occupied/unknown слоя E34",
|
||||
);
|
||||
renderer.domElement.setAttribute("role", "img");
|
||||
host.prepend(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 500);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.enablePan = true;
|
||||
controls.enableZoom = true;
|
||||
controls.screenSpacePanning = true;
|
||||
controls.minDistance = 0.5;
|
||||
controls.maxDistance = 160;
|
||||
controls.target.set(0, 0, 0);
|
||||
|
||||
const content = new THREE.Group();
|
||||
scene.add(content);
|
||||
sceneRef.current = scene;
|
||||
cameraRef.current = camera;
|
||||
controlsRef.current = controls;
|
||||
contentRef.current = content;
|
||||
|
||||
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);
|
||||
};
|
||||
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();
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
observer.disconnect();
|
||||
controls.dispose();
|
||||
scene.traverse(disposeRenderable);
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
sceneRef.current = null;
|
||||
cameraRef.current = null;
|
||||
controlsRef.current = null;
|
||||
contentRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!host || !content) return;
|
||||
while (content.children.length) {
|
||||
const child = content.children[0];
|
||||
if (!child) continue;
|
||||
content.remove(child);
|
||||
child.traverse(disposeRenderable);
|
||||
}
|
||||
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
contextGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(
|
||||
positions(frame.cellCentersMapXyzM, origin),
|
||||
3,
|
||||
),
|
||||
);
|
||||
const context = new THREE.Points(
|
||||
contextGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
size: 2.4,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.32,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
content.add(context);
|
||||
|
||||
for (const component of frame.components) {
|
||||
const color = componentColor(host, component);
|
||||
const markerGeometry = new THREE.BufferGeometry();
|
||||
markerGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(
|
||||
positions([component.centroidMapXyzM], origin),
|
||||
3,
|
||||
),
|
||||
);
|
||||
const marker = new THREE.Points(
|
||||
markerGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color,
|
||||
size: component.state === "expired" ? 8 : 6,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: component.state === "expired" ? 0.72 : 1,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
content.add(marker);
|
||||
|
||||
if (component.history.length > 1) {
|
||||
const trailGeometry = new THREE.BufferGeometry();
|
||||
trailGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(
|
||||
positions(
|
||||
component.history.map((item) => item.centroidMapXyzM),
|
||||
origin,
|
||||
),
|
||||
3,
|
||||
),
|
||||
);
|
||||
content.add(new THREE.Line(
|
||||
trailGeometry,
|
||||
new THREE.LineBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: component.state === "expired" ? 0.28 : 0.54,
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const radius = fitRadius(frame, origin);
|
||||
viewRadiusRef.current = radius;
|
||||
const grid = new THREE.GridHelper(
|
||||
radius * 2.4,
|
||||
20,
|
||||
tokenColor(host, "--nodedc-text-muted", [96, 99, 106]),
|
||||
tokenColor(host, "--nodedc-glass-outline", [48, 50, 56]),
|
||||
);
|
||||
const materials = Array.isArray(grid.material)
|
||||
? grid.material
|
||||
: [grid.material];
|
||||
materials.forEach((material) => {
|
||||
material.transparent = true;
|
||||
material.opacity = 0.15;
|
||||
material.depthWrite = false;
|
||||
});
|
||||
content.add(grid);
|
||||
}, [frame, origin]);
|
||||
|
||||
const resetView = () => {
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
if (!camera || !controls) return;
|
||||
const radius = viewRadiusRef.current;
|
||||
controls.target.set(0, 0, 0);
|
||||
if (mode === "plan") {
|
||||
camera.position.set(0, radius * 2.8, 0.001);
|
||||
camera.up.set(0, 0, -1);
|
||||
} else {
|
||||
camera.position.set(radius * 1.35, radius * 0.9, radius * 1.35);
|
||||
camera.up.set(0, 1, 0);
|
||||
}
|
||||
camera.near = Math.max(radius / 2_000, 0.005);
|
||||
camera.far = Math.max(radius * 20, 120);
|
||||
camera.updateProjectionMatrix();
|
||||
controls.maxDistance = Math.max(radius * 8, 40);
|
||||
controls.update();
|
||||
};
|
||||
|
||||
useEffect(resetView, [frame, mode]);
|
||||
|
||||
return (
|
||||
<div className="e34-temporal-scene">
|
||||
<div ref={hostRef} className="e34-temporal-scene__viewport">
|
||||
{renderError ? (
|
||||
<p className="e34-temporal-scene__error">{renderError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="e34-temporal-scene__toolbar">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
onClick={resetView}
|
||||
>
|
||||
Сбросить ракурс
|
||||
</Button>
|
||||
<div className="e34-temporal-scene__gestures">
|
||||
<span>ЛКМ · вращение</span>
|
||||
<span>Колесо · масштаб</span>
|
||||
<span>ПКМ · панорама</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="e34-temporal-scene__legend">
|
||||
<span data-state="cells">
|
||||
Ячейки · {frame.cellCentersMapXyzM.length}
|
||||
</span>
|
||||
<span data-state="current">Наблюдается · {frame.counts.current}</span>
|
||||
<span data-state="held">Удерживается · {frame.counts.held}</span>
|
||||
<span data-state="expired">Истекло · {frame.counts.expired}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,9 +29,7 @@ import {
|
||||
} from "../../core/laboratory/e30Review";
|
||||
import {
|
||||
fetchAdvancedLaboratoryResults,
|
||||
type E31LaboratoryResult,
|
||||
type E32LaboratoryResult,
|
||||
type E33LaboratoryResult,
|
||||
type AdvancedLaboratoryResults,
|
||||
} from "../../core/laboratory/advancedResults";
|
||||
import {
|
||||
fetchLidarLocalSurfaces,
|
||||
@@ -41,14 +39,17 @@ import { formatNumber } from "../../presentation";
|
||||
import { E30ReviewWorkspace } from "../E30ReviewWorkspace";
|
||||
import { LidarQualityWorkspace } from "../LidarQualityWorkspace";
|
||||
import type { WorkspaceRendererProps } from "../contracts";
|
||||
import { E31Result } from "./E31Result";
|
||||
import { E32Result } from "./E32Result";
|
||||
import { E33Result } from "./E33Result";
|
||||
import {
|
||||
AdvancedLaboratoryResult,
|
||||
advancedLaboratorySourceSession,
|
||||
advancedLaboratoryWorkOptions,
|
||||
isAdvancedLaboratoryWorkId,
|
||||
type AdvancedLaboratoryWorkId,
|
||||
} from "./AdvancedLaboratoryResult";
|
||||
import {
|
||||
e28LaboratoryBrief, e29LaboratoryBrief,
|
||||
e30LaboratoryBrief, PUBLISHED_LABORATORY_BRIEF,
|
||||
} from "./laboratoryArchiveBriefs";
|
||||
import { RecordedReplayEvidence } from "./RecordedReplayEvidence";
|
||||
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||
};
|
||||
@@ -58,11 +59,16 @@ type LaboratoryWorkId =
|
||||
| "e28-local-surface"
|
||||
| "e29-camera-geometry"
|
||||
| "e30-evidence-review"
|
||||
| "e31-source-binding"
|
||||
| "e32-track-geometry"
|
||||
| "e33-worker-shadow"
|
||||
| AdvancedLaboratoryWorkId
|
||||
| `session:${string}`;
|
||||
|
||||
const EMPTY_ADVANCED_RESULTS: AdvancedLaboratoryResults = {
|
||||
e31: null,
|
||||
e32: null,
|
||||
e33: null,
|
||||
e34: null,
|
||||
};
|
||||
|
||||
function digestFromContentId(value: string | null | undefined): string | null {
|
||||
const digest = value?.split("-").at(-1) ?? "";
|
||||
return /^[a-f0-9]{64}$/.test(digest) ? digest : null;
|
||||
@@ -554,9 +560,9 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
||||
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
||||
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
|
||||
const [e31Result, setE31Result] = useState<E31LaboratoryResult | null>(null);
|
||||
const [e32Result, setE32Result] = useState<E32LaboratoryResult | null>(null);
|
||||
const [e33Result, setE33Result] = useState<E33LaboratoryResult | null>(null);
|
||||
const [advancedResults, setAdvancedResults] = useState(
|
||||
EMPTY_ADVANCED_RESULTS,
|
||||
);
|
||||
const [evidenceLoading, setEvidenceLoading] = useState(true);
|
||||
const [evidenceError, setEvidenceError] = useState<string | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
@@ -598,14 +604,12 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
setE28Model(nextE28);
|
||||
setE29Result(nextE29);
|
||||
setE30Result(nextE30);
|
||||
setE31Result(nextAdvanced?.e31 ?? null);
|
||||
setE32Result(nextAdvanced?.e32 ?? null);
|
||||
setE33Result(nextAdvanced?.e33 ?? null);
|
||||
setAdvancedResults(nextAdvanced ?? EMPTY_ADVANCED_RESULTS);
|
||||
const failures = [
|
||||
e28.status === "rejected" ? "E28" : null,
|
||||
e29.status === "rejected" ? "E29" : null,
|
||||
e30.status === "rejected" ? "E30" : null,
|
||||
advanced.status === "rejected" ? "E31–E33" : null,
|
||||
advanced.status === "rejected" ? "E31–E34" : null,
|
||||
].filter(Boolean);
|
||||
setEvidenceError(
|
||||
failures.length
|
||||
@@ -651,32 +655,16 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
label: "LAB E30 · evidence review A2",
|
||||
});
|
||||
}
|
||||
if (e31Result && sourceSessions.has(e31Result.sourceSessionId)) {
|
||||
items.push({
|
||||
id: "e31-source-binding",
|
||||
label: "LAB E31 · source binding",
|
||||
});
|
||||
}
|
||||
if (e32Result && sourceSessions.has(e32Result.sourceSessionId)) {
|
||||
items.push({
|
||||
id: "e32-track-geometry",
|
||||
label: "LAB E32 · TrackGeometry v1",
|
||||
});
|
||||
}
|
||||
if (e33Result && sourceSessions.has(e33Result.sourceSessionId)) {
|
||||
items.push({
|
||||
id: "e33-worker-shadow",
|
||||
label: "LAB E33 · worker shadow 1×",
|
||||
});
|
||||
}
|
||||
items.push(...advancedLaboratoryWorkOptions(
|
||||
advancedResults,
|
||||
sourceSessions,
|
||||
));
|
||||
return items;
|
||||
}, [
|
||||
advancedResults,
|
||||
e28Model,
|
||||
e29Result,
|
||||
e30Result,
|
||||
e31Result,
|
||||
e32Result,
|
||||
e33Result,
|
||||
sourceSessions,
|
||||
]);
|
||||
const profiles = useMemo(() => {
|
||||
@@ -714,15 +702,6 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
const e30SourceSession = e30Result
|
||||
? sourceSessions.get(e30Result.sourceSessionId) ?? null
|
||||
: null;
|
||||
const e31SourceSession = e31Result
|
||||
? sourceSessions.get(e31Result.sourceSessionId) ?? null
|
||||
: null;
|
||||
const e32SourceSession = e32Result
|
||||
? sourceSessions.get(e32Result.sourceSessionId) ?? null
|
||||
: null;
|
||||
const e33SourceSession = e33Result
|
||||
? sourceSessions.get(e33Result.sourceSessionId) ?? null
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -786,13 +765,13 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
void sessions.replay(e30SourceSession.id);
|
||||
return;
|
||||
}
|
||||
const advancedSession = next === "e31-source-binding"
|
||||
? e31SourceSession
|
||||
: next === "e32-track-geometry"
|
||||
? e32SourceSession
|
||||
: next === "e33-worker-shadow"
|
||||
? e33SourceSession
|
||||
: null;
|
||||
const advancedSession = isAdvancedLaboratoryWorkId(next)
|
||||
? advancedLaboratorySourceSession(
|
||||
next,
|
||||
advancedResults,
|
||||
sourceSessions,
|
||||
)
|
||||
: null;
|
||||
if (advancedSession) {
|
||||
void sessions.replay(advancedSession.id);
|
||||
}
|
||||
@@ -940,44 +919,16 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
result={e30Result}
|
||||
sourceSession={e30SourceSession}
|
||||
/>
|
||||
) : workId === "e31-source-binding" && e31Result && e31SourceSession ? (
|
||||
<E31Result
|
||||
) : isAdvancedLaboratoryWorkId(workId) ? (
|
||||
<AdvancedLaboratoryResult
|
||||
props={props}
|
||||
rigLabel={rigLabel}
|
||||
result={e31Result}
|
||||
evidence={(
|
||||
<RecordedReplayEvidence
|
||||
props={props}
|
||||
sourceSession={e31SourceSession}
|
||||
loading={sessions.replayingSessionId === e31SourceSession.id}
|
||||
error={sessions.failedSessionId === e31SourceSession.id ? sessions.error : null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : workId === "e32-track-geometry" && e32Result && e32SourceSession ? (
|
||||
<E32Result
|
||||
rigLabel={rigLabel}
|
||||
result={e32Result}
|
||||
evidence={(
|
||||
<RecordedReplayEvidence
|
||||
props={props}
|
||||
sourceSession={e32SourceSession}
|
||||
loading={sessions.replayingSessionId === e32SourceSession.id}
|
||||
error={sessions.failedSessionId === e32SourceSession.id ? sessions.error : null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : workId === "e33-worker-shadow" && e33Result && e33SourceSession ? (
|
||||
<E33Result
|
||||
rigLabel={rigLabel}
|
||||
result={e33Result}
|
||||
evidence={(
|
||||
<RecordedReplayEvidence
|
||||
props={props}
|
||||
sourceSession={e33SourceSession}
|
||||
loading={sessions.replayingSessionId === e33SourceSession.id}
|
||||
error={sessions.failedSessionId === e33SourceSession.id ? sessions.error : null}
|
||||
/>
|
||||
)}
|
||||
workId={workId}
|
||||
results={advancedResults}
|
||||
sourceSessions={sourceSessions}
|
||||
replayingSessionId={sessions.replayingSessionId}
|
||||
failedSessionId={sessions.failedSessionId}
|
||||
replayError={sessions.error}
|
||||
/>
|
||||
) : selectedSession ? (
|
||||
<PublishedLaboratoryResult
|
||||
|
||||
@@ -123,6 +123,89 @@ function e33() {
|
||||
};
|
||||
}
|
||||
|
||||
function e34() {
|
||||
return {
|
||||
result_id: `e34-temporal-occupied-${"4".repeat(64)}`,
|
||||
created_at_utc: "2026-07-27T13:28:30Z",
|
||||
source_session_id: "20260720T065719Z_viewer_live",
|
||||
status: "accepted-bounded-occupied-unknown-temporal-layer",
|
||||
e32_result_id: e32().result_id,
|
||||
e33_result_id: e33().result_id,
|
||||
profile_id: "e34-short-ttl-hit-only-occupied-unknown/v1",
|
||||
pipeline_id: "lidar-local-map/short-ttl-hit-only/v1",
|
||||
coordinate_frame: "map",
|
||||
configuration: {
|
||||
voxel_size_m: 0.45,
|
||||
occupied_ttl_seconds: 0.75,
|
||||
maximum_active_components: 256,
|
||||
maximum_cells_per_component: 4096,
|
||||
geometry_maximum_gap_seconds: 0.35,
|
||||
geometry_maximum_centroid_distance_m: 0.9,
|
||||
},
|
||||
metrics: {
|
||||
source_frames: 4489,
|
||||
processed_frames: 4489,
|
||||
frames_with_current_layer: 3928,
|
||||
held_only_frames: 540,
|
||||
unknown_empty_frames: 21,
|
||||
created_components: 4912,
|
||||
camera_created_components: 1027,
|
||||
geometry_created_components: 3885,
|
||||
geometry_reassociated_components: 2366,
|
||||
exact_camera_associations: 5168,
|
||||
geometry_spatial_reassociations: 17433,
|
||||
held_publications: 39664,
|
||||
expired_components: 4895,
|
||||
peak_active_components: 37,
|
||||
maximum_observed_span_seconds: 13.394,
|
||||
continuity_fraction: 0.821466,
|
||||
geometry_only_reassociation_fraction: 0.81776,
|
||||
e32_current_point_rows: 2119302,
|
||||
e34_consumed_current_point_rows: 2119302,
|
||||
current_cell_rows: 834478,
|
||||
held_cell_rows: 531581,
|
||||
stored_cell_rows: 1366059,
|
||||
free_cell_rows: 0,
|
||||
peak_cells_per_component: 324,
|
||||
maximum_held_age_seconds: 0.75,
|
||||
maximum_expiry_delay_seconds: 0,
|
||||
maximum_replay_materialization_delay_seconds: 0.283,
|
||||
map_frame_jump_candidates: 0,
|
||||
frame_processing_p95_ms: 1.483,
|
||||
build_elapsed_ms: 4317,
|
||||
},
|
||||
review: {
|
||||
schema_version: "missioncore.e34-temporal-review-timeline/v1",
|
||||
result_id: `e34-temporal-occupied-${"4".repeat(64)}`,
|
||||
frames: [{
|
||||
frame_index: 238,
|
||||
session_seconds: 59.206,
|
||||
source_available: false,
|
||||
layer_state: "unknown",
|
||||
counts: { current: 0, held: 0, expired: 1 },
|
||||
cell_centers_map_xyz_m: [],
|
||||
components: [{
|
||||
temporal_id: 136,
|
||||
state: "expired",
|
||||
occupancy_state: "unknown",
|
||||
owner_kind: "camera-track",
|
||||
centroid_map_xyz_m: [20.25, -2.83, 0.21],
|
||||
last_observed_age_seconds: 0.75,
|
||||
association_reason: "independent-ttl-deadline",
|
||||
history_tail: [{
|
||||
frame_index: 230,
|
||||
session_seconds: 58.456,
|
||||
centroid_map_xyz_m: [20.25, -2.83, 0.21],
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
acceptance: { accepted: true },
|
||||
authority,
|
||||
access: "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
@@ -139,9 +222,9 @@ after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("decodes E31, E32 and E33 from separate read-only catalogs", async () => {
|
||||
test("decodes E31–E34 from separate read-only catalogs", async () => {
|
||||
const requests = [];
|
||||
const items = [e31(), e32(), e33()];
|
||||
const items = [e31(), e32(), e33(), e34()];
|
||||
const decoded = await fetchAdvancedLaboratoryResults({
|
||||
fetcher: async (input, init) => {
|
||||
requests.push({ input: String(input), method: init?.method });
|
||||
@@ -156,10 +239,13 @@ test("decodes E31, E32 and E33 from separate read-only catalogs", async () => {
|
||||
assert.equal(decoded.e33.metrics.deliveredFrames, 4489);
|
||||
assert.equal(decoded.e33.metrics.sourceAvailableFrames, 3928);
|
||||
assert.equal(decoded.e33.metrics.resultAgeMaxMs, 13.638);
|
||||
assert.equal(decoded.e34.metrics.processedFrames, 4489);
|
||||
assert.equal(decoded.e34.reviewFrames[0].components[0].state, "expired");
|
||||
assert.deepEqual(requests, [
|
||||
{ input: "/api/v1/laboratory/e31/results?limit=1", method: "GET" },
|
||||
{ input: "/api/v1/laboratory/e32/results?limit=1", method: "GET" },
|
||||
{ input: "/api/v1/laboratory/e33/results?limit=1", method: "GET" },
|
||||
{ input: "/api/v1/laboratory/e34/results?limit=1", method: "GET" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -172,7 +258,8 @@ test("rejects authority escalation in an accepted-looking result", async () => {
|
||||
() => fetchAdvancedLaboratoryResults({
|
||||
fetcher: async (input) => new Response(JSON.stringify(catalog(
|
||||
String(input).includes("/e31/") ? forged
|
||||
: String(input).includes("/e32/") ? e32() : e33(),
|
||||
: String(input).includes("/e32/") ? e32()
|
||||
: String(input).includes("/e33/") ? e33() : e34(),
|
||||
)), { status: 200 }),
|
||||
}),
|
||||
AdvancedLaboratoryContractError,
|
||||
|
||||
@@ -22,6 +22,14 @@ const e33ResultUrl = new URL(
|
||||
"../src/workspaces/laboratory/E33Result.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const e34ResultUrl = new URL(
|
||||
"../src/workspaces/laboratory/E34Result.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const advancedLaboratoryResultUrl = new URL(
|
||||
"../src/workspaces/laboratory/AdvancedLaboratoryResult.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const workspacesUrl = new URL(
|
||||
"../src/workspaces/Workspaces.tsx",
|
||||
import.meta.url,
|
||||
@@ -136,6 +144,23 @@ test("E33 explains the experiment, its method and its retained limits", async ()
|
||||
assert.doesNotMatch(e33Source, /name: result\.e32ResultId/);
|
||||
});
|
||||
|
||||
test("E34 keeps temporal evidence inside the canonical LAB and viewer contracts", async () => {
|
||||
const [e34Source, advancedSource] = await Promise.all([
|
||||
readFile(e34ResultUrl, "utf8"),
|
||||
readFile(advancedLaboratoryResultUrl, "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(e34Source, /<LaboratoryWorkTemplate/);
|
||||
assert.match(e34Source, /<LaboratoryEvidenceViewer/);
|
||||
assert.match(e34Source, /\{ value: "3d", label: "3D" \}/);
|
||||
assert.match(e34Source, /\{ value: "plan", label: "План" \}/);
|
||||
assert.match(e34Source, /Первый immutable replay был отклонён/);
|
||||
assert.match(e34Source, /Слой не вычисляет свободное пространство/);
|
||||
assert.match(e34Source, /Следующий критический gate — E35/);
|
||||
assert.match(advancedSource, /id: "e34-temporal-layer"/);
|
||||
assert.match(advancedSource, /<E34Result/);
|
||||
});
|
||||
|
||||
test("the primary point-cloud viewer restores from fullscreen on Escape", async () => {
|
||||
const workspacesSource = await readFile(workspacesUrl, "utf8");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user