fix(ui): scope spatial tools to scene
This commit is contained in:
@@ -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, /Открыть слои/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user