fix(ui): scope spatial tools to scene
This commit is contained in:
parent
574a494759
commit
0ac6424c46
|
|
@ -46,7 +46,6 @@ import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayout
|
|||
import {
|
||||
OBSERVATION_WORKSPACE_ID,
|
||||
OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||
type ObservationToolWindowId,
|
||||
type ObservationWorkspaceLayoutProfile,
|
||||
} from "./core/observation/workspaceLayout";
|
||||
import {
|
||||
|
|
@ -67,9 +66,7 @@ import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
|||
import { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||
import "./styles/scene-windows.css";
|
||||
|
||||
type SceneToolWindowId = ObservationToolWindowId;
|
||||
|
||||
const sceneToolWindowIds: readonly SceneToolWindowId[] = ["sources", "display", "layers"];
|
||||
type SceneToolWindowId = "sources" | "display" | "layers";
|
||||
const viewerSettingsQuietPeriodMs = 750;
|
||||
|
||||
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
||||
|
|
@ -166,6 +163,9 @@ export default function App() {
|
|||
|
||||
const currentRoot = rootById(activeRoot);
|
||||
const activeDefinition = workspaceById(workspace.activeView);
|
||||
const spatialWorkspaceActive = Boolean(
|
||||
workspace.contentOpen && activeDefinition?.kind === "spatial",
|
||||
);
|
||||
const rootWorkspaces = workspacesForRoot(activeRoot);
|
||||
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
|
||||
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
|
||||
|
|
@ -292,17 +292,19 @@ export default function App() {
|
|||
confirmedSceneSettingsRef.current = profile.sceneSettings;
|
||||
setSceneSettings(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, 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(() => {
|
||||
sceneSettingsCommitterActiveRef.current = true;
|
||||
return () => {
|
||||
|
|
@ -355,7 +357,7 @@ export default function App() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSceneWindow) return;
|
||||
if (!spatialWorkspaceActive || !activeSceneWindow) return;
|
||||
|
||||
const closeActiveWindow = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
|
|
@ -367,7 +369,7 @@ export default function App() {
|
|||
|
||||
document.addEventListener("keydown", closeActiveWindow);
|
||||
return () => document.removeEventListener("keydown", closeActiveWindow);
|
||||
}, [activeSceneWindow, closeSceneWindow]);
|
||||
}, [activeSceneWindow, closeSceneWindow, spatialWorkspaceActive]);
|
||||
|
||||
const selectRoot = (rootId: RootId) => {
|
||||
setActiveRoot(rootId);
|
||||
|
|
@ -383,6 +385,7 @@ export default function App() {
|
|||
};
|
||||
|
||||
const openSource = () => {
|
||||
if (!spatialWorkspaceActive) return;
|
||||
setSourceDraft(sourceUrl);
|
||||
setSourceWindowOpen(true);
|
||||
activateSceneWindow("sources");
|
||||
|
|
@ -423,11 +426,13 @@ export default function App() {
|
|||
}, [commitDisplaySettings]);
|
||||
|
||||
const openDisplay = () => {
|
||||
if (!spatialWorkspaceActive) return;
|
||||
setDisplayWindowOpen(true);
|
||||
activateSceneWindow("display");
|
||||
};
|
||||
|
||||
const openLayers = () => {
|
||||
if (!spatialWorkspaceActive) return;
|
||||
setLayerInspectorOpen(true);
|
||||
activateSceneWindow("layers");
|
||||
};
|
||||
|
|
@ -494,37 +499,18 @@ export default function App() {
|
|||
setLayoutSaveNotice("Сцена ещё не измерила рабочую область.");
|
||||
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 = {
|
||||
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
|
||||
revision: workspaceLayoutProfile.profile?.revision ?? 0,
|
||||
workspaceId: OBSERVATION_WORKSPACE_ID,
|
||||
sceneSettings: sceneSettingsRef.current,
|
||||
toolWindows: {
|
||||
sourcesOpen: sourceWindowOpen,
|
||||
displayOpen: displayWindowOpen,
|
||||
layersOpen: layerInspectorOpen,
|
||||
order: completeWindowOrder,
|
||||
},
|
||||
...layout,
|
||||
};
|
||||
const saved = await workspaceLayoutProfile.save(draft);
|
||||
setLayoutSaveNotice(saved ? "Компоновка сохранена" : workspaceLayoutProfile.error);
|
||||
}, [
|
||||
displayWindowOpen,
|
||||
flushDisplaySettings,
|
||||
layerInspectorOpen,
|
||||
observationLayout.snapshot,
|
||||
sceneWindowOrder,
|
||||
sourceWindowOpen,
|
||||
workspaceLayoutProfile,
|
||||
]);
|
||||
|
||||
|
|
@ -555,9 +541,6 @@ export default function App() {
|
|||
disabled: workspaceLayoutProfile.state === "saving",
|
||||
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
|
||||
open={sourceWindowOpen}
|
||||
open={spatialWorkspaceActive && sourceWindowOpen}
|
||||
title="Визуальный движок"
|
||||
subtitle="Rerun gRPC и записи пространственной сцены"
|
||||
placement="end"
|
||||
|
|
@ -857,7 +840,7 @@ export default function App() {
|
|||
</Window>
|
||||
|
||||
<Window
|
||||
open={displayWindowOpen}
|
||||
open={spatialWorkspaceActive && displayWindowOpen}
|
||||
title="Отображение"
|
||||
subtitle="Параметры пространственной сцены"
|
||||
placement="end"
|
||||
|
|
@ -1002,7 +985,7 @@ export default function App() {
|
|||
</Window>
|
||||
|
||||
<Window
|
||||
open={layerInspectorOpen}
|
||||
open={spatialWorkspaceActive && layerInspectorOpen}
|
||||
title="Слои сцены"
|
||||
subtitle="Сущности пространственной сцены"
|
||||
placement="end"
|
||||
|
|
|
|||
|
|
@ -1,19 +1,10 @@
|
|||
import type { SceneSettings } from "../../sceneSettings";
|
||||
|
||||
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 =
|
||||
"/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 {
|
||||
x: number;
|
||||
y: number;
|
||||
|
|
@ -51,7 +42,6 @@ export interface ObservationWorkspaceLayoutProfile extends ObservationLayoutSnap
|
|||
revision: number;
|
||||
workspaceId: typeof OBSERVATION_WORKSPACE_ID;
|
||||
sceneSettings: SceneSettings;
|
||||
toolWindows: ObservationToolWindows;
|
||||
}
|
||||
|
||||
export type WorkspaceLayoutFetch = (
|
||||
|
|
@ -76,12 +66,6 @@ type WireProfile = {
|
|||
show_labels: boolean;
|
||||
show_camera_frustums: boolean;
|
||||
};
|
||||
tool_windows: {
|
||||
sources_open: boolean;
|
||||
display_open: boolean;
|
||||
layers_open: boolean;
|
||||
order: ObservationToolWindowId[];
|
||||
};
|
||||
visible_source_ids: string[];
|
||||
active_floating_source_id: string | null;
|
||||
window_rects: Record<string, NormalizedObservationWindowRect>;
|
||||
|
|
@ -93,7 +77,6 @@ const PROFILE_KEYS = new Set([
|
|||
"revision",
|
||||
"workspace_id",
|
||||
"scene_settings",
|
||||
"tool_windows",
|
||||
"visible_source_ids",
|
||||
"active_floating_source_id",
|
||||
"window_rects",
|
||||
|
|
@ -112,7 +95,6 @@ const SCENE_KEYS = new Set([
|
|||
"show_labels",
|
||||
"show_camera_frustums",
|
||||
]);
|
||||
const TOOL_WINDOW_KEYS = new Set(["sources_open", "display_open", "layers_open", "order"]);
|
||||
const VIEWPORT_KEYS = new Set(["width", "height"]);
|
||||
const RECT_KEYS = new Set(["x", "y", "width", "height"]);
|
||||
const PROJECTIONS = new Set<SceneSettings["projection"]>(["3d", "2d", "map"]);
|
||||
|
|
@ -130,7 +112,6 @@ const PALETTES = new Set<SceneSettings["palette"]>([
|
|||
"grayscale",
|
||||
"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 HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
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(
|
||||
value: unknown,
|
||||
): ObservationWorkspaceLayoutProfile {
|
||||
|
|
@ -376,7 +338,6 @@ export function decodeObservationWorkspaceLayoutProfile(
|
|||
}),
|
||||
workspaceId: OBSERVATION_WORKSPACE_ID,
|
||||
sceneSettings: decodeSceneSettings(record.scene_settings),
|
||||
toolWindows: decodeToolWindows(record.tool_windows),
|
||||
visibleSourceIds,
|
||||
activeFloatingSourceId,
|
||||
windowRects: decodeWindowRects(record.window_rects),
|
||||
|
|
@ -404,12 +365,6 @@ export function encodeObservationWorkspaceLayoutProfile(
|
|||
show_labels: profile.sceneSettings.showLabels,
|
||||
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],
|
||||
active_floating_source_id: profile.activeFloatingSourceId,
|
||||
window_rects: Object.fromEntries(
|
||||
|
|
@ -482,12 +437,6 @@ export function validateObservationLayoutSnapshot(
|
|||
show_labels: false,
|
||||
show_camera_frustums: true,
|
||||
},
|
||||
tool_windows: {
|
||||
sources_open: false,
|
||||
display_open: false,
|
||||
layers_open: false,
|
||||
order: ["sources", "display", "layers"],
|
||||
},
|
||||
visible_source_ids: [...snapshot.visibleSourceIds],
|
||||
active_floating_source_id: snapshot.activeFloatingSourceId,
|
||||
window_rects: snapshot.windowRects,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
|
@ -41,7 +42,7 @@ after(async () => {
|
|||
|
||||
function wireProfile(overrides = {}) {
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
revision: 7,
|
||||
workspace_id: "observation.spatial",
|
||||
scene_settings: {
|
||||
|
|
@ -57,12 +58,6 @@ function wireProfile(overrides = {}) {
|
|||
show_labels: false,
|
||||
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"],
|
||||
active_floating_source_id: "camera.left",
|
||||
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 profile = decodeObservationWorkspaceLayoutProfile(wire);
|
||||
|
||||
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.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", () => {
|
||||
|
|
@ -93,7 +88,7 @@ test("workspace layout rejects unknown fields, transient transport data and inva
|
|||
WorkspaceLayoutContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ version: 2 })),
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ version: 1 })),
|
||||
/Неподдерживаемая версия/,
|
||||
);
|
||||
assert.throws(
|
||||
|
|
@ -114,15 +109,16 @@ test("workspace layout rejects unknown fields, transient transport data and inva
|
|||
/выходит за нормализованные границы/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
|
||||
() => decodeObservationWorkspaceLayoutProfile({
|
||||
...wireProfile(),
|
||||
tool_windows: {
|
||||
sources_open: true,
|
||||
display_open: true,
|
||||
layers_open: true,
|
||||
order: ["sources", "sources", "layers"],
|
||||
order: ["sources", "display", "layers"],
|
||||
},
|
||||
})),
|
||||
/перестановкой/,
|
||||
}),
|
||||
/неверная схема/,
|
||||
);
|
||||
assert.throws(
|
||||
() => 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",
|
||||
);
|
||||
});
|
||||
|
||||
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, /Открыть слои/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,8 +36,10 @@ sessions contain no recoverable video.
|
|||
the right half of the bottom timeline. The left half changes point-cloud
|
||||
accumulation. The recorded timeline is zero-based `session_time`.
|
||||
8. Use the first disk button to save the current workspace layout. The next
|
||||
opening restores display settings, tool windows and dynamic source-window
|
||||
positions. This action saves no sensor evidence.
|
||||
opening restores display settings and dynamic sensor-source window
|
||||
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.
|
||||
Every nonterminal or unknown acquisition state fail-closes saved-session replay,
|
||||
|
|
|
|||
|
|
@ -167,16 +167,19 @@ The first disk action in the spatial workspace saves only the versioned
|
|||
`observation.spatial` layout document:
|
||||
|
||||
- scene display settings;
|
||||
- open tool windows and their z-order;
|
||||
- visible dynamic source identifiers;
|
||||
- active floating source;
|
||||
- source-window rectangles normalized to the observed viewport.
|
||||
|
||||
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.
|
||||
Schema v2 deliberately excludes open/closed state and z-order of the transient
|
||||
**Движок / Слои / Отображение** tool windows. They are children of the mounted
|
||||
spatial workspace, close on every workspace/root/content exit and always start
|
||||
closed. Startup migration removes the obsolete `tool_windows` object from a
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -175,10 +175,7 @@ class SessionStore:
|
|||
).fetchone()
|
||||
if cursor_row is None:
|
||||
raise SessionNotFoundError("observation session cursor was not found")
|
||||
where = (
|
||||
"WHERE (COALESCE(started_at_utc, ''), session_id) < "
|
||||
"(COALESCE(?, ''), ?)"
|
||||
)
|
||||
where = "WHERE (COALESCE(started_at_utc, ''), session_id) < (COALESCE(?, ''), ?)"
|
||||
parameters.extend((cursor_row["started_at_utc"], cursor_row["session_id"]))
|
||||
parameters.append(limit + 1)
|
||||
rows = connection.execute(
|
||||
|
|
@ -309,8 +306,7 @@ class SessionStore:
|
|||
_validate_identifier(session_id, "session id")
|
||||
with self._connect() as connection:
|
||||
session = connection.execute(
|
||||
"SELECT allowed_root, session_root FROM observation_sessions "
|
||||
"WHERE session_id = ?",
|
||||
"SELECT allowed_root, session_root FROM observation_sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if session is None:
|
||||
|
|
@ -372,8 +368,8 @@ class SessionStore:
|
|||
layout: dict[str, Any],
|
||||
) -> WorkspaceLayout:
|
||||
_validate_identifier(workspace_id, "workspace id")
|
||||
if schema_version != 1:
|
||||
raise ValueError("only workspace layout schema version 1 is supported")
|
||||
if schema_version not in {1, 2}:
|
||||
raise ValueError("workspace layout schema version is unsupported")
|
||||
if expected_revision < 0:
|
||||
raise ValueError("expected revision must be non-negative")
|
||||
normalized_name = name.strip()
|
||||
|
|
@ -423,9 +419,9 @@ class SessionStore:
|
|||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(SCHEMA_SQL)
|
||||
self._migrate_transient_tool_windows(connection)
|
||||
session_columns = {
|
||||
row["name"]
|
||||
for row in connection.execute("PRAGMA table_info(observation_sessions)")
|
||||
row["name"] for row in connection.execute("PRAGMA table_info(observation_sessions)")
|
||||
}
|
||||
for name, declaration in (
|
||||
("plugin_id", "TEXT NOT NULL DEFAULT ''"),
|
||||
|
|
@ -441,9 +437,7 @@ class SessionStore:
|
|||
)
|
||||
artifact_columns = {
|
||||
row["name"]
|
||||
for row in connection.execute(
|
||||
"PRAGMA table_info(observation_session_artifacts)"
|
||||
)
|
||||
for row in connection.execute("PRAGMA table_info(observation_session_artifacts)")
|
||||
}
|
||||
if "replay_byte_length" not in artifact_columns:
|
||||
connection.execute(
|
||||
|
|
@ -454,6 +448,29 @@ class SessionStore:
|
|||
with _ignore_os_error():
|
||||
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(
|
||||
self,
|
||||
source: ObservationArchiveSource,
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ class SceneSettingsDocument(StrictApiModel):
|
|||
show_camera_frustums: bool
|
||||
|
||||
|
||||
class ToolWindowsDocument(StrictApiModel):
|
||||
class LegacyToolWindowsDocument(StrictApiModel):
|
||||
sources_open: bool
|
||||
display_open: bool
|
||||
layers_open: bool
|
||||
|
|
@ -151,12 +151,23 @@ class ViewportSize(StrictApiModel):
|
|||
height: float = Field(gt=0.0, le=100_000.0)
|
||||
|
||||
|
||||
class LayoutPutRequest(StrictApiModel):
|
||||
class LegacyLayoutV1Document(StrictApiModel):
|
||||
version: Literal[1]
|
||||
revision: int = Field(ge=0, le=MAX_SAFE_INTEGER)
|
||||
workspace_id: Literal["observation.spatial"]
|
||||
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]
|
||||
active_floating_source_id: str | None
|
||||
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]:
|
||||
try:
|
||||
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}"'
|
||||
return document
|
||||
except SessionNotFoundError as exc:
|
||||
|
|
@ -824,7 +840,12 @@ def build_session_router(
|
|||
layout=payload,
|
||||
)
|
||||
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:
|
||||
raise HTTPException(status_code=412, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
|
|
@ -885,11 +906,13 @@ def _parse_if_match(value: str) -> int:
|
|||
|
||||
def _layout_document(
|
||||
workspace_id: str,
|
||||
schema_version: int,
|
||||
revision: int,
|
||||
layout: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if schema_version == 1:
|
||||
try:
|
||||
document = LayoutPutRequest.model_validate(
|
||||
legacy = LegacyLayoutV1Document.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"revision": revision,
|
||||
|
|
@ -898,7 +921,27 @@ def _layout_document(
|
|||
}
|
||||
)
|
||||
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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
get_route = endpoint(router, "/api/v1/workspace-layouts/{workspace_id}", "GET")
|
||||
request = LayoutPutRequest(
|
||||
version=1,
|
||||
version=2,
|
||||
revision=0,
|
||||
workspace_id="observation.spatial",
|
||||
scene_settings={
|
||||
|
|
@ -1678,12 +1678,6 @@ def test_layout_routes_round_trip_versioned_document(tmp_path: Path) -> None:
|
|||
"show_labels": False,
|
||||
"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"],
|
||||
active_floating_source_id=None,
|
||||
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",
|
||||
"workspace_id",
|
||||
"scene_settings",
|
||||
"tool_windows",
|
||||
"visible_source_ids",
|
||||
"active_floating_source_id",
|
||||
"window_rects",
|
||||
"viewport_size",
|
||||
}
|
||||
assert loaded["version"] == 1
|
||||
assert loaded["version"] == 2
|
||||
assert loaded["workspace_id"] == "observation.spatial"
|
||||
assert loaded["revision"] == 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"',
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -788,3 +788,31 @@ def test_layout_save_is_atomic_and_revision_checked(tmp_path: Path) -> None:
|
|||
)
|
||||
assert second.revision == 2
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue