fix(ui): scope spatial tools to scene

This commit is contained in:
DCCONSTRUCTIONS 2026-07-18 11:31:44 +03:00
parent 574a494759
commit 0ac6424c46
9 changed files with 216 additions and 142 deletions

View File

@ -46,7 +46,6 @@ import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayout
import { import {
OBSERVATION_WORKSPACE_ID, OBSERVATION_WORKSPACE_ID,
OBSERVATION_WORKSPACE_LAYOUT_VERSION, OBSERVATION_WORKSPACE_LAYOUT_VERSION,
type ObservationToolWindowId,
type ObservationWorkspaceLayoutProfile, type ObservationWorkspaceLayoutProfile,
} from "./core/observation/workspaceLayout"; } from "./core/observation/workspaceLayout";
import { import {
@ -67,9 +66,7 @@ import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { WorkspaceRenderer } from "./workspaces/Workspaces"; import { WorkspaceRenderer } from "./workspaces/Workspaces";
import "./styles/scene-windows.css"; import "./styles/scene-windows.css";
type SceneToolWindowId = ObservationToolWindowId; type SceneToolWindowId = "sources" | "display" | "layers";
const sceneToolWindowIds: readonly SceneToolWindowId[] = ["sources", "display", "layers"];
const viewerSettingsQuietPeriodMs = 750; const viewerSettingsQuietPeriodMs = 750;
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [ const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
@ -166,6 +163,9 @@ export default function App() {
const currentRoot = rootById(activeRoot); const currentRoot = rootById(activeRoot);
const activeDefinition = workspaceById(workspace.activeView); const activeDefinition = workspaceById(workspace.activeView);
const spatialWorkspaceActive = Boolean(
workspace.contentOpen && activeDefinition?.kind === "spatial",
);
const rootWorkspaces = workspacesForRoot(activeRoot); const rootWorkspaces = workspacesForRoot(activeRoot);
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null; const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? ""; const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
@ -292,17 +292,19 @@ export default function App() {
confirmedSceneSettingsRef.current = profile.sceneSettings; confirmedSceneSettingsRef.current = profile.sceneSettings;
setSceneSettings(profile.sceneSettings); setSceneSettings(profile.sceneSettings);
setDisplayDraft(profile.sceneSettings); setDisplayDraft(profile.sceneSettings);
setSourceWindowOpen(profile.toolWindows.sourcesOpen);
setDisplayWindowOpen(profile.toolWindows.displayOpen);
setLayerInspectorOpen(profile.toolWindows.layersOpen);
setSceneWindowOrder(profile.toolWindows.order.filter((windowId) => {
if (windowId === "sources") return profile.toolWindows.sourcesOpen;
if (windowId === "display") return profile.toolWindows.displayOpen;
return profile.toolWindows.layersOpen;
}));
observationLayout.restore(profile); observationLayout.restore(profile);
}, [observationLayout.restore, workspaceLayoutProfile.profile]); }, [observationLayout.restore, workspaceLayoutProfile.profile]);
useEffect(() => {
if (spatialWorkspaceActive) return;
// Scene tools are transient children of the spatial workspace. They must
// never survive a route/root/content close or appear over the landing page.
setSourceWindowOpen(false);
setDisplayWindowOpen(false);
setLayerInspectorOpen(false);
setSceneWindowOrder([]);
}, [spatialWorkspaceActive]);
useEffect(() => { useEffect(() => {
sceneSettingsCommitterActiveRef.current = true; sceneSettingsCommitterActiveRef.current = true;
return () => { return () => {
@ -355,7 +357,7 @@ export default function App() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!activeSceneWindow) return; if (!spatialWorkspaceActive || !activeSceneWindow) return;
const closeActiveWindow = (event: KeyboardEvent) => { const closeActiveWindow = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented) return; if (event.key !== "Escape" || event.defaultPrevented) return;
@ -367,7 +369,7 @@ export default function App() {
document.addEventListener("keydown", closeActiveWindow); document.addEventListener("keydown", closeActiveWindow);
return () => document.removeEventListener("keydown", closeActiveWindow); return () => document.removeEventListener("keydown", closeActiveWindow);
}, [activeSceneWindow, closeSceneWindow]); }, [activeSceneWindow, closeSceneWindow, spatialWorkspaceActive]);
const selectRoot = (rootId: RootId) => { const selectRoot = (rootId: RootId) => {
setActiveRoot(rootId); setActiveRoot(rootId);
@ -383,6 +385,7 @@ export default function App() {
}; };
const openSource = () => { const openSource = () => {
if (!spatialWorkspaceActive) return;
setSourceDraft(sourceUrl); setSourceDraft(sourceUrl);
setSourceWindowOpen(true); setSourceWindowOpen(true);
activateSceneWindow("sources"); activateSceneWindow("sources");
@ -423,11 +426,13 @@ export default function App() {
}, [commitDisplaySettings]); }, [commitDisplaySettings]);
const openDisplay = () => { const openDisplay = () => {
if (!spatialWorkspaceActive) return;
setDisplayWindowOpen(true); setDisplayWindowOpen(true);
activateSceneWindow("display"); activateSceneWindow("display");
}; };
const openLayers = () => { const openLayers = () => {
if (!spatialWorkspaceActive) return;
setLayerInspectorOpen(true); setLayerInspectorOpen(true);
activateSceneWindow("layers"); activateSceneWindow("layers");
}; };
@ -494,37 +499,18 @@ export default function App() {
setLayoutSaveNotice("Сцена ещё не измерила рабочую область."); setLayoutSaveNotice("Сцена ещё не измерила рабочую область.");
return; return;
} }
const openWindowOrder = sceneWindowOrder.filter((windowId) => {
if (windowId === "sources") return sourceWindowOpen;
if (windowId === "display") return displayWindowOpen;
return layerInspectorOpen;
});
const completeWindowOrder = [
...sceneToolWindowIds.filter((windowId) => !openWindowOrder.includes(windowId)),
...openWindowOrder,
];
const draft: ObservationWorkspaceLayoutProfile = { const draft: ObservationWorkspaceLayoutProfile = {
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION, version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
revision: workspaceLayoutProfile.profile?.revision ?? 0, revision: workspaceLayoutProfile.profile?.revision ?? 0,
workspaceId: OBSERVATION_WORKSPACE_ID, workspaceId: OBSERVATION_WORKSPACE_ID,
sceneSettings: sceneSettingsRef.current, sceneSettings: sceneSettingsRef.current,
toolWindows: {
sourcesOpen: sourceWindowOpen,
displayOpen: displayWindowOpen,
layersOpen: layerInspectorOpen,
order: completeWindowOrder,
},
...layout, ...layout,
}; };
const saved = await workspaceLayoutProfile.save(draft); const saved = await workspaceLayoutProfile.save(draft);
setLayoutSaveNotice(saved ? "Компоновка сохранена" : workspaceLayoutProfile.error); setLayoutSaveNotice(saved ? "Компоновка сохранена" : workspaceLayoutProfile.error);
}, [ }, [
displayWindowOpen,
flushDisplaySettings, flushDisplaySettings,
layerInspectorOpen,
observationLayout.snapshot, observationLayout.snapshot,
sceneWindowOrder,
sourceWindowOpen,
workspaceLayoutProfile, workspaceLayoutProfile,
]); ]);
@ -555,9 +541,6 @@ export default function App() {
disabled: workspaceLayoutProfile.state === "saving", disabled: workspaceLayoutProfile.state === "saving",
onClick: () => void saveWorkspaceLayout(), onClick: () => void saveWorkspaceLayout(),
}, },
{ label: "Настроить визуальный движок", icon: "network", onClick: openSource },
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
{ label: "Открыть слои", icon: "list", onClick: openLayers },
); );
} }
@ -731,7 +714,7 @@ export default function App() {
/> />
<Window <Window
open={sourceWindowOpen} open={spatialWorkspaceActive && sourceWindowOpen}
title="Визуальный движок" title="Визуальный движок"
subtitle="Rerun gRPC и записи пространственной сцены" subtitle="Rerun gRPC и записи пространственной сцены"
placement="end" placement="end"
@ -857,7 +840,7 @@ export default function App() {
</Window> </Window>
<Window <Window
open={displayWindowOpen} open={spatialWorkspaceActive && displayWindowOpen}
title="Отображение" title="Отображение"
subtitle="Параметры пространственной сцены" subtitle="Параметры пространственной сцены"
placement="end" placement="end"
@ -1002,7 +985,7 @@ export default function App() {
</Window> </Window>
<Window <Window
open={layerInspectorOpen} open={spatialWorkspaceActive && layerInspectorOpen}
title="Слои сцены" title="Слои сцены"
subtitle="Сущности пространственной сцены" subtitle="Сущности пространственной сцены"
placement="end" placement="end"

View File

@ -1,19 +1,10 @@
import type { SceneSettings } from "../../sceneSettings"; import type { SceneSettings } from "../../sceneSettings";
export const OBSERVATION_WORKSPACE_ID = "observation.spatial" as const; export const OBSERVATION_WORKSPACE_ID = "observation.spatial" as const;
export const OBSERVATION_WORKSPACE_LAYOUT_VERSION = 1 as const; export const OBSERVATION_WORKSPACE_LAYOUT_VERSION = 2 as const;
export const OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT = export const OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT =
"/api/v1/workspace-layouts/observation.spatial" as const; "/api/v1/workspace-layouts/observation.spatial" as const;
export type ObservationToolWindowId = "sources" | "display" | "layers";
export interface ObservationToolWindows {
sourcesOpen: boolean;
displayOpen: boolean;
layersOpen: boolean;
order: readonly ObservationToolWindowId[];
}
export interface ObservationWindowRect { export interface ObservationWindowRect {
x: number; x: number;
y: number; y: number;
@ -51,7 +42,6 @@ export interface ObservationWorkspaceLayoutProfile extends ObservationLayoutSnap
revision: number; revision: number;
workspaceId: typeof OBSERVATION_WORKSPACE_ID; workspaceId: typeof OBSERVATION_WORKSPACE_ID;
sceneSettings: SceneSettings; sceneSettings: SceneSettings;
toolWindows: ObservationToolWindows;
} }
export type WorkspaceLayoutFetch = ( export type WorkspaceLayoutFetch = (
@ -76,12 +66,6 @@ type WireProfile = {
show_labels: boolean; show_labels: boolean;
show_camera_frustums: boolean; show_camera_frustums: boolean;
}; };
tool_windows: {
sources_open: boolean;
display_open: boolean;
layers_open: boolean;
order: ObservationToolWindowId[];
};
visible_source_ids: string[]; visible_source_ids: string[];
active_floating_source_id: string | null; active_floating_source_id: string | null;
window_rects: Record<string, NormalizedObservationWindowRect>; window_rects: Record<string, NormalizedObservationWindowRect>;
@ -93,7 +77,6 @@ const PROFILE_KEYS = new Set([
"revision", "revision",
"workspace_id", "workspace_id",
"scene_settings", "scene_settings",
"tool_windows",
"visible_source_ids", "visible_source_ids",
"active_floating_source_id", "active_floating_source_id",
"window_rects", "window_rects",
@ -112,7 +95,6 @@ const SCENE_KEYS = new Set([
"show_labels", "show_labels",
"show_camera_frustums", "show_camera_frustums",
]); ]);
const TOOL_WINDOW_KEYS = new Set(["sources_open", "display_open", "layers_open", "order"]);
const VIEWPORT_KEYS = new Set(["width", "height"]); const VIEWPORT_KEYS = new Set(["width", "height"]);
const RECT_KEYS = new Set(["x", "y", "width", "height"]); const RECT_KEYS = new Set(["x", "y", "width", "height"]);
const PROJECTIONS = new Set<SceneSettings["projection"]>(["3d", "2d", "map"]); const PROJECTIONS = new Set<SceneSettings["projection"]>(["3d", "2d", "map"]);
@ -130,7 +112,6 @@ const PALETTES = new Set<SceneSettings["palette"]>([
"grayscale", "grayscale",
"custom", "custom",
]); ]);
const TOOL_WINDOW_IDS = new Set<ObservationToolWindowId>(["sources", "display", "layers"]);
const SAFE_STABLE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; const SAFE_STABLE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const MAX_SOURCES = 256; const MAX_SOURCES = 256;
@ -328,25 +309,6 @@ function decodeSceneSettings(value: unknown): SceneSettings {
}; };
} }
function decodeToolWindows(value: unknown): ObservationToolWindows {
const record = requireRecord(value, "tool_windows");
assertExactKeys(record, TOOL_WINDOW_KEYS, "tool_windows");
if (!Array.isArray(record.order) || record.order.length !== TOOL_WINDOW_IDS.size) {
throw new WorkspaceLayoutContractError("Поле tool_windows.order должно содержать три окна.");
}
const order = record.order.map((entry, index) =>
requireEnum(entry, TOOL_WINDOW_IDS, `tool_windows.order[${index}]`));
if (new Set(order).size !== TOOL_WINDOW_IDS.size) {
throw new WorkspaceLayoutContractError("Поле tool_windows.order должно быть перестановкой окон.");
}
return {
sourcesOpen: requireBoolean(record.sources_open, "tool_windows.sources_open"),
displayOpen: requireBoolean(record.display_open, "tool_windows.display_open"),
layersOpen: requireBoolean(record.layers_open, "tool_windows.layers_open"),
order,
};
}
export function decodeObservationWorkspaceLayoutProfile( export function decodeObservationWorkspaceLayoutProfile(
value: unknown, value: unknown,
): ObservationWorkspaceLayoutProfile { ): ObservationWorkspaceLayoutProfile {
@ -376,7 +338,6 @@ export function decodeObservationWorkspaceLayoutProfile(
}), }),
workspaceId: OBSERVATION_WORKSPACE_ID, workspaceId: OBSERVATION_WORKSPACE_ID,
sceneSettings: decodeSceneSettings(record.scene_settings), sceneSettings: decodeSceneSettings(record.scene_settings),
toolWindows: decodeToolWindows(record.tool_windows),
visibleSourceIds, visibleSourceIds,
activeFloatingSourceId, activeFloatingSourceId,
windowRects: decodeWindowRects(record.window_rects), windowRects: decodeWindowRects(record.window_rects),
@ -404,12 +365,6 @@ export function encodeObservationWorkspaceLayoutProfile(
show_labels: profile.sceneSettings.showLabels, show_labels: profile.sceneSettings.showLabels,
show_camera_frustums: profile.sceneSettings.showCameraFrustums, show_camera_frustums: profile.sceneSettings.showCameraFrustums,
}, },
tool_windows: {
sources_open: profile.toolWindows.sourcesOpen,
display_open: profile.toolWindows.displayOpen,
layers_open: profile.toolWindows.layersOpen,
order: [...profile.toolWindows.order],
},
visible_source_ids: [...profile.visibleSourceIds], visible_source_ids: [...profile.visibleSourceIds],
active_floating_source_id: profile.activeFloatingSourceId, active_floating_source_id: profile.activeFloatingSourceId,
window_rects: Object.fromEntries( window_rects: Object.fromEntries(
@ -482,12 +437,6 @@ export function validateObservationLayoutSnapshot(
show_labels: false, show_labels: false,
show_camera_frustums: true, show_camera_frustums: true,
}, },
tool_windows: {
sources_open: false,
display_open: false,
layers_open: false,
order: ["sources", "display", "layers"],
},
visible_source_ids: [...snapshot.visibleSourceIds], visible_source_ids: [...snapshot.visibleSourceIds],
active_floating_source_id: snapshot.activeFloatingSourceId, active_floating_source_id: snapshot.activeFloatingSourceId,
window_rects: snapshot.windowRects, window_rects: snapshot.windowRects,

View File

@ -1,4 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test"; import { after, before, test } from "node:test";
import { createServer } from "vite"; import { createServer } from "vite";
@ -41,7 +42,7 @@ after(async () => {
function wireProfile(overrides = {}) { function wireProfile(overrides = {}) {
return { return {
version: 1, version: 2,
revision: 7, revision: 7,
workspace_id: "observation.spatial", workspace_id: "observation.spatial",
scene_settings: { scene_settings: {
@ -57,12 +58,6 @@ function wireProfile(overrides = {}) {
show_labels: false, show_labels: false,
show_camera_frustums: true, show_camera_frustums: true,
}, },
tool_windows: {
sources_open: true,
display_open: false,
layers_open: true,
order: ["sources", "layers", "display"],
},
visible_source_ids: ["spatial.point-cloud.live", "camera.left"], visible_source_ids: ["spatial.point-cloud.live", "camera.left"],
active_floating_source_id: "camera.left", active_floating_source_id: "camera.left",
window_rects: { window_rects: {
@ -74,14 +69,14 @@ function wireProfile(overrides = {}) {
}; };
} }
test("workspace layout performs a lossless strict wire/camel/wire round trip", () => { test("workspace layout v2 excludes transient tool-window state", () => {
const wire = wireProfile(); const wire = wireProfile();
const profile = decodeObservationWorkspaceLayoutProfile(wire); const profile = decodeObservationWorkspaceLayoutProfile(wire);
assert.deepEqual(profile.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]); assert.deepEqual(profile.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
assert.deepEqual(profile.toolWindows.order, ["sources", "layers", "display"]);
assert.equal(profile.sceneSettings.pointSize, 2.5); assert.equal(profile.sceneSettings.pointSize, 2.5);
assert.deepEqual(encodeObservationWorkspaceLayoutProfile(profile), wire); assert.deepEqual(encodeObservationWorkspaceLayoutProfile(profile), wire);
assert.equal("tool_windows" in encodeObservationWorkspaceLayoutProfile(profile), false);
}); });
test("workspace layout rejects unknown fields, transient transport data and invalid schema values", () => { test("workspace layout rejects unknown fields, transient transport data and invalid schema values", () => {
@ -93,7 +88,7 @@ test("workspace layout rejects unknown fields, transient transport data and inva
WorkspaceLayoutContractError, WorkspaceLayoutContractError,
); );
assert.throws( assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ version: 2 })), () => decodeObservationWorkspaceLayoutProfile(wireProfile({ version: 1 })),
/Неподдерживаемая версия/, /Неподдерживаемая версия/,
); );
assert.throws( assert.throws(
@ -114,15 +109,16 @@ test("workspace layout rejects unknown fields, transient transport data and inva
/выходит за нормализованные границы/, /выходит за нормализованные границы/,
); );
assert.throws( assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ () => decodeObservationWorkspaceLayoutProfile({
...wireProfile(),
tool_windows: { tool_windows: {
sources_open: true, sources_open: true,
display_open: true, display_open: true,
layers_open: true, layers_open: true,
order: ["sources", "sources", "layers"], order: ["sources", "display", "layers"],
}, },
})), }),
/перестановкой/, /неверная схема/,
); );
assert.throws( assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ () => decodeObservationWorkspaceLayoutProfile(wireProfile({
@ -227,3 +223,21 @@ test("workspace layout API treats 404 as no profile and exposes revision conflic
error.conflict && error.status === 412 && error.message === "revision mismatch", error.conflict && error.status === 412 && error.message === "revision mismatch",
); );
}); });
test("scene tool windows are route-scoped and toolbar actions are not duplicated", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
const utilityStart = appSource.indexOf("const contentActions");
const utilityEnd = appSource.indexOf("const header", utilityStart);
const utilitySource = appSource.slice(utilityStart, utilityEnd);
assert.match(appSource, /open=\{spatialWorkspaceActive && sourceWindowOpen\}/);
assert.match(appSource, /open=\{spatialWorkspaceActive && displayWindowOpen\}/);
assert.match(appSource, /open=\{spatialWorkspaceActive && layerInspectorOpen\}/);
assert.match(
appSource,
/if \(spatialWorkspaceActive\) return;[\s\S]*setSceneWindowOrder\(\[\]\)/,
);
assert.doesNotMatch(utilitySource, /Настроить визуальный движок/);
assert.doesNotMatch(utilitySource, /Настроить отображение/);
assert.doesNotMatch(utilitySource, /Открыть слои/);
});

View File

@ -36,8 +36,10 @@ sessions contain no recoverable video.
the right half of the bottom timeline. The left half changes point-cloud the right half of the bottom timeline. The left half changes point-cloud
accumulation. The recorded timeline is zero-based `session_time`. accumulation. The recorded timeline is zero-based `session_time`.
8. Use the first disk button to save the current workspace layout. The next 8. Use the first disk button to save the current workspace layout. The next
opening restores display settings, tool windows and dynamic source-window opening restores display settings and dynamic sensor-source window
positions. This action saves no sensor evidence. positions. The transient **Движок / Слои / Отображение** tool windows are
scoped to the mounted spatial workspace and are never persisted. This action
saves no sensor evidence.
Mission Core does not switch an active acquisition to another spatial source. Mission Core does not switch an active acquisition to another spatial source.
Every nonterminal or unknown acquisition state fail-closes saved-session replay, Every nonterminal or unknown acquisition state fail-closes saved-session replay,

View File

@ -167,16 +167,19 @@ The first disk action in the spatial workspace saves only the versioned
`observation.spatial` layout document: `observation.spatial` layout document:
- scene display settings; - scene display settings;
- open tool windows and their z-order;
- visible dynamic source identifiers; - visible dynamic source identifiers;
- active floating source; - active floating source;
- source-window rectangles normalized to the observed viewport. - source-window rectangles normalized to the observed viewport.
The document uses optimistic revision control (`ETag` and `If-Match`) and is Schema v2 deliberately excludes open/closed state and z-order of the transient
restored automatically on the next opening. Unknown source identifiers remain **Движок / Слои / Отображение** tool windows. They are children of the mounted
desired state so a temporarily disconnected camera can recover its saved spatial workspace, close on every workspace/root/content exit and always start
window when that source returns. The save action never starts, completes or closed. Startup migration removes the obsolete `tool_windows` object from a
modifies an observation session. stored schema-v1 row. The document uses optimistic revision control (`ETag` and
`If-Match`) and is restored automatically on the next opening. Unknown source
identifiers remain desired state so a temporarily disconnected camera can
recover its saved window when that source returns. The save action never starts,
completes or modifies an observation session.
### API boundary ### API boundary

View File

@ -175,10 +175,7 @@ class SessionStore:
).fetchone() ).fetchone()
if cursor_row is None: if cursor_row is None:
raise SessionNotFoundError("observation session cursor was not found") raise SessionNotFoundError("observation session cursor was not found")
where = ( where = "WHERE (COALESCE(started_at_utc, ''), session_id) < (COALESCE(?, ''), ?)"
"WHERE (COALESCE(started_at_utc, ''), session_id) < "
"(COALESCE(?, ''), ?)"
)
parameters.extend((cursor_row["started_at_utc"], cursor_row["session_id"])) parameters.extend((cursor_row["started_at_utc"], cursor_row["session_id"]))
parameters.append(limit + 1) parameters.append(limit + 1)
rows = connection.execute( rows = connection.execute(
@ -309,8 +306,7 @@ class SessionStore:
_validate_identifier(session_id, "session id") _validate_identifier(session_id, "session id")
with self._connect() as connection: with self._connect() as connection:
session = connection.execute( session = connection.execute(
"SELECT allowed_root, session_root FROM observation_sessions " "SELECT allowed_root, session_root FROM observation_sessions WHERE session_id = ?",
"WHERE session_id = ?",
(session_id,), (session_id,),
).fetchone() ).fetchone()
if session is None: if session is None:
@ -372,8 +368,8 @@ class SessionStore:
layout: dict[str, Any], layout: dict[str, Any],
) -> WorkspaceLayout: ) -> WorkspaceLayout:
_validate_identifier(workspace_id, "workspace id") _validate_identifier(workspace_id, "workspace id")
if schema_version != 1: if schema_version not in {1, 2}:
raise ValueError("only workspace layout schema version 1 is supported") raise ValueError("workspace layout schema version is unsupported")
if expected_revision < 0: if expected_revision < 0:
raise ValueError("expected revision must be non-negative") raise ValueError("expected revision must be non-negative")
normalized_name = name.strip() normalized_name = name.strip()
@ -423,9 +419,9 @@ class SessionStore:
def _initialize(self) -> None: def _initialize(self) -> None:
with self._connect() as connection: with self._connect() as connection:
connection.executescript(SCHEMA_SQL) connection.executescript(SCHEMA_SQL)
self._migrate_transient_tool_windows(connection)
session_columns = { session_columns = {
row["name"] row["name"] for row in connection.execute("PRAGMA table_info(observation_sessions)")
for row in connection.execute("PRAGMA table_info(observation_sessions)")
} }
for name, declaration in ( for name, declaration in (
("plugin_id", "TEXT NOT NULL DEFAULT ''"), ("plugin_id", "TEXT NOT NULL DEFAULT ''"),
@ -441,9 +437,7 @@ class SessionStore:
) )
artifact_columns = { artifact_columns = {
row["name"] row["name"]
for row in connection.execute( for row in connection.execute("PRAGMA table_info(observation_session_artifacts)")
"PRAGMA table_info(observation_session_artifacts)"
)
} }
if "replay_byte_length" not in artifact_columns: if "replay_byte_length" not in artifact_columns:
connection.execute( connection.execute(
@ -454,6 +448,29 @@ class SessionStore:
with _ignore_os_error(): with _ignore_os_error():
self.database_path.chmod(0o600) self.database_path.chmod(0o600)
def _migrate_transient_tool_windows(self, connection: sqlite3.Connection) -> None:
"""Remove v1 scene-tool visibility that never belonged in a layout."""
rows = connection.execute(
"SELECT workspace_id, layout_json FROM workspace_layouts "
"WHERE workspace_id = ? AND layout_schema_version = 1",
("observation.spatial",),
).fetchall()
for row in rows:
try:
layout = json.loads(row["layout_json"])
except (TypeError, json.JSONDecodeError):
continue
if not isinstance(layout, dict) or "tool_windows" not in layout:
continue
layout.pop("tool_windows", None)
connection.execute(
"UPDATE workspace_layouts "
"SET layout_schema_version = 2, layout_json = ? "
"WHERE workspace_id = ? AND layout_schema_version = 1",
(_serialize_layout(layout), row["workspace_id"]),
)
def _upsert_candidate( def _upsert_candidate(
self, self,
source: ObservationArchiveSource, source: ObservationArchiveSource,

View File

@ -118,7 +118,7 @@ class SceneSettingsDocument(StrictApiModel):
show_camera_frustums: bool show_camera_frustums: bool
class ToolWindowsDocument(StrictApiModel): class LegacyToolWindowsDocument(StrictApiModel):
sources_open: bool sources_open: bool
display_open: bool display_open: bool
layers_open: bool layers_open: bool
@ -151,12 +151,23 @@ class ViewportSize(StrictApiModel):
height: float = Field(gt=0.0, le=100_000.0) height: float = Field(gt=0.0, le=100_000.0)
class LayoutPutRequest(StrictApiModel): class LegacyLayoutV1Document(StrictApiModel):
version: Literal[1] version: Literal[1]
revision: int = Field(ge=0, le=MAX_SAFE_INTEGER) revision: int = Field(ge=0, le=MAX_SAFE_INTEGER)
workspace_id: Literal["observation.spatial"] workspace_id: Literal["observation.spatial"]
scene_settings: SceneSettingsDocument scene_settings: SceneSettingsDocument
tool_windows: ToolWindowsDocument tool_windows: LegacyToolWindowsDocument
visible_source_ids: list[str]
active_floating_source_id: str | None
window_rects: dict[str, NormalizedWindowRect]
viewport_size: ViewportSize
class LayoutPutRequest(StrictApiModel):
version: Literal[2]
revision: int = Field(ge=0, le=MAX_SAFE_INTEGER)
workspace_id: Literal["observation.spatial"]
scene_settings: SceneSettingsDocument
visible_source_ids: list[str] visible_source_ids: list[str]
active_floating_source_id: str | None active_floating_source_id: str | None
window_rects: dict[str, NormalizedWindowRect] window_rects: dict[str, NormalizedWindowRect]
@ -791,7 +802,12 @@ def build_session_router(
def get_workspace_layout(workspace_id: str, response: Response) -> dict[str, Any]: def get_workspace_layout(workspace_id: str, response: Response) -> dict[str, Any]:
try: try:
stored = store.get_layout(workspace_id) stored = store.get_layout(workspace_id)
document = _layout_document(stored.workspace_id, stored.revision, stored.layout) document = _layout_document(
stored.workspace_id,
stored.schema_version,
stored.revision,
stored.layout,
)
response.headers["ETag"] = f'"{stored.revision}"' response.headers["ETag"] = f'"{stored.revision}"'
return document return document
except SessionNotFoundError as exc: except SessionNotFoundError as exc:
@ -824,7 +840,12 @@ def build_session_router(
layout=payload, layout=payload,
) )
response.headers["ETag"] = f'"{stored.revision}"' response.headers["ETag"] = f'"{stored.revision}"'
return _layout_document(stored.workspace_id, stored.revision, stored.layout) return _layout_document(
stored.workspace_id,
stored.schema_version,
stored.revision,
stored.layout,
)
except LayoutConflictError as exc: except LayoutConflictError as exc:
raise HTTPException(status_code=412, detail=str(exc)) from exc raise HTTPException(status_code=412, detail=str(exc)) from exc
except ValueError as exc: except ValueError as exc:
@ -885,11 +906,13 @@ def _parse_if_match(value: str) -> int:
def _layout_document( def _layout_document(
workspace_id: str, workspace_id: str,
schema_version: int,
revision: int, revision: int,
layout: dict[str, Any], layout: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
if schema_version == 1:
try: try:
document = LayoutPutRequest.model_validate( legacy = LegacyLayoutV1Document.model_validate(
{ {
"version": 1, "version": 1,
"revision": revision, "revision": revision,
@ -898,7 +921,27 @@ def _layout_document(
} }
) )
except ValueError as exc: except ValueError as exc:
raise SessionIntegrityError("stored workspace layout violates schema version 1") from exc raise SessionIntegrityError(
"stored workspace layout violates schema version 1"
) from exc
layout = legacy.model_dump(
mode="json",
exclude={"version", "revision", "workspace_id", "tool_windows"},
)
schema_version = 2
if schema_version != 2:
raise SessionIntegrityError("stored workspace layout schema is unsupported")
try:
document = LayoutPutRequest.model_validate(
{
"version": 2,
"revision": revision,
"workspace_id": workspace_id,
**layout,
}
)
except ValueError as exc:
raise SessionIntegrityError("stored workspace layout violates schema version 2") from exc
return document.model_dump(mode="json") return document.model_dump(mode="json")

View File

@ -1662,7 +1662,7 @@ def test_layout_routes_round_trip_versioned_document(tmp_path: Path) -> None:
put_route = endpoint(router, "/api/v1/workspace-layouts/{workspace_id}", "PUT") put_route = endpoint(router, "/api/v1/workspace-layouts/{workspace_id}", "PUT")
get_route = endpoint(router, "/api/v1/workspace-layouts/{workspace_id}", "GET") get_route = endpoint(router, "/api/v1/workspace-layouts/{workspace_id}", "GET")
request = LayoutPutRequest( request = LayoutPutRequest(
version=1, version=2,
revision=0, revision=0,
workspace_id="observation.spatial", workspace_id="observation.spatial",
scene_settings={ scene_settings={
@ -1678,12 +1678,6 @@ def test_layout_routes_round_trip_versioned_document(tmp_path: Path) -> None:
"show_labels": False, "show_labels": False,
"show_camera_frustums": True, "show_camera_frustums": True,
}, },
tool_windows={
"sources_open": True,
"display_open": False,
"layers_open": False,
"order": ["sources", "display", "layers"],
},
visible_source_ids=["sensor.lidar.primary"], visible_source_ids=["sensor.lidar.primary"],
active_floating_source_id=None, active_floating_source_id=None,
window_rects={"sensor.camera.left": {"x": 0.65, "y": 0.65, "width": 0.3, "height": 0.3}}, window_rects={"sensor.camera.left": {"x": 0.65, "y": 0.65, "width": 0.3, "height": 0.3}},
@ -1706,13 +1700,12 @@ def test_layout_routes_round_trip_versioned_document(tmp_path: Path) -> None:
"revision", "revision",
"workspace_id", "workspace_id",
"scene_settings", "scene_settings",
"tool_windows",
"visible_source_ids", "visible_source_ids",
"active_floating_source_id", "active_floating_source_id",
"window_rects", "window_rects",
"viewport_size", "viewport_size",
} }
assert loaded["version"] == 1 assert loaded["version"] == 2
assert loaded["workspace_id"] == "observation.spatial" assert loaded["workspace_id"] == "observation.spatial"
assert loaded["revision"] == 1 assert loaded["revision"] == 1
assert put_response.headers["etag"] == '"1"' assert put_response.headers["etag"] == '"1"'
@ -1727,3 +1720,45 @@ def test_layout_routes_round_trip_versioned_document(tmp_path: Path) -> None:
if_match='"0"', if_match='"0"',
) )
assert conflict.value.status_code == 412 assert conflict.value.status_code == 412
def test_layout_get_migrates_legacy_tool_windows_out_of_the_contract(tmp_path: Path) -> None:
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
store.save_layout(
"observation.spatial",
schema_version=1,
expected_revision=0,
name="Пространственная сцена",
layout={
"scene_settings": {
"projection": "3d",
"point_size": 3.0,
"color_mode": "intensity",
"palette": "turbo",
"custom_color": "#35d7c1",
"accumulation_seconds": 12,
"show_points": True,
"show_trajectory": True,
"show_grid": True,
"show_labels": False,
"show_camera_frustums": True,
},
"tool_windows": {
"sources_open": True,
"display_open": True,
"layers_open": True,
"order": ["layers", "sources", "display"],
},
"visible_source_ids": [],
"active_floating_source_id": None,
"window_rects": {},
"viewport_size": {"width": 1440, "height": 900},
},
)
router = build_session_router(store)
get_route = endpoint(router, "/api/v1/workspace-layouts/{workspace_id}", "GET")
loaded = get_route(workspace_id="observation.spatial", response=Response())
assert loaded["version"] == 2
assert "tool_windows" not in loaded

View File

@ -788,3 +788,31 @@ def test_layout_save_is_atomic_and_revision_checked(tmp_path: Path) -> None:
) )
assert second.revision == 2 assert second.revision == 2
assert store.get_layout("observation.spatial").layout["visible_source_ids"] == [] assert store.get_layout("observation.spatial").layout["visible_source_ids"] == []
def test_layout_startup_migration_removes_transient_tool_window_state(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
data_dir = tmp_path / "data"
initial = SessionStore(repository, data_dir=data_dir)
initial.save_layout(
"observation.spatial",
schema_version=1,
expected_revision=0,
name="Пространственная сцена",
layout={
"tool_windows": {
"sources_open": True,
"display_open": True,
"layers_open": True,
"order": ["sources", "display", "layers"],
},
"visible_source_ids": [],
},
)
migrated = SessionStore(repository, data_dir=data_dir).get_layout("observation.spatial")
assert migrated.schema_version == 2
assert "tool_windows" not in migrated.layout