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
+22 -39
View File
@@ -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, /Открыть слои/);
});