Files
workinf_Blender_Wasm/web/protocol/web-preferences.ts
mes123456 371427a6bc feat(web): add dynamic area layouts and camera views
Area headers now expose a real ⋮ menu with split, join, swap,
duplicate, maximize/focus, close and reset; the first operation
measures the CSS grid into normalized rects and switches that
workspace to an OPFS-persisted custom layout while reusing the
existing React content per slot.

ViewportNavigation gains Numpad0 camera view with last-view restore,
framing-only navigation when unlocked, Camera to View lock that writes
the camera object (rotation-mode-aware native setObjectTransform),
and Ctrl+Alt+Numpad0 camera-to-view. Camera lock persists per area.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-13 09:50:12 +08:00

284 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { EditorType } from "./ui-schema";
export const WEB_PREFERENCES_SCHEMA_VERSION = 1 as const;
export const WEB_SETTINGS_STORAGE_KEY = "web-blender-interface-v2";
export const LEGACY_SETTINGS_STORAGE_KEY = "web-blender-interface-v1";
export const WEB_SETTINGS_PATH = "settings/web-interface.json";
export type PreferenceTab = "interface" | "viewport" | "animation" | "input" | "keymap" | "saveLoad" | "files" | "themes" | "system";
export interface WebPreferencesIR {
schemaVersion: typeof WEB_PREFERENCES_SCHEMA_VERSION;
interface: {
showStatus: boolean;
quickTools: boolean;
showAreaSwitcher: boolean;
showObjectInfo: boolean;
showViewName: boolean;
showPlaybackFps: boolean;
playbackFpsSamples: number;
recentFiles: number;
statusbarVersion: boolean;
statusbarExtensions: boolean;
startupWorkspace: string;
};
viewport: {
gizmoSize: number;
gizmoSizeNavigate: number;
miniAxis: "MINIMAL" | "GIZMO";
viewportAA: number;
overlaySmoothWire: boolean;
navigationSensitivity: number;
defaultShading: "SOLID" | "MATERIAL" | "RENDERED";
};
animation: {
useAutoKeying: boolean;
autoKeyingMode: "ADD_REPLACE_KEYS" | "REPLACE_KEYS";
keyframeInterpolation: "BEZIER" | "LINEAR" | "CONSTANT";
keyframeHandle: "AUTO_CLAMPED" | "AUTO" | "VECTOR" | "ALIGNED" | "FREE";
};
navigation: {
orbitSensitivity: number;
zoomSensitivity: number;
invertZoom: boolean;
};
keymap: {
activeKeyconfig: string;
bindings: Record<string, string>;
};
saveLoad: {
autoSaveTimeMinutes: number;
useAutoSaveTemporaryFiles: boolean;
saveVersion: number;
usePreferencesSave: boolean;
};
files: {
note: string;
};
}
export interface AreaLayoutRecord {
layoutMode: "custom";
areas: Array<{
id: string;
editor: EditorType;
contentKey: string | null;
rect: { x: number; y: number; width: number; height: number };
view3d?: { cameraLock: boolean };
}>;
activeAreaId: string;
focusAreaId?: string | null;
maximizedAreaId?: string | null;
}
export interface WebSettingsFile {
schemaVersion: typeof WEB_PREFERENCES_SCHEMA_VERSION;
updatedAt: string;
preferences: WebPreferencesIR;
areaLayouts: Record<string, AreaLayoutRecord>;
areaEditorOverrides: Record<string, EditorType>;
cameraLocks: Record<string, boolean>;
}
export const DEFAULT_WEB_PREFERENCES: WebPreferencesIR = {
schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION,
interface: {
showStatus: true,
quickTools: false,
showAreaSwitcher: true,
showObjectInfo: true,
showViewName: true,
showPlaybackFps: true,
playbackFpsSamples: 8,
recentFiles: 200,
statusbarVersion: true,
statusbarExtensions: true,
startupWorkspace: "Layout",
},
viewport: {
gizmoSize: 75,
gizmoSizeNavigate: 80,
miniAxis: "GIZMO",
viewportAA: 8,
overlaySmoothWire: false,
navigationSensitivity: 1,
defaultShading: "MATERIAL",
},
animation: {
useAutoKeying: false,
autoKeyingMode: "ADD_REPLACE_KEYS",
keyframeInterpolation: "BEZIER",
keyframeHandle: "AUTO_CLAMPED",
},
navigation: { orbitSensitivity: 1, zoomSensitivity: 1, invertZoom: false },
keymap: { activeKeyconfig: "Blender", bindings: {} },
saveLoad: {
autoSaveTimeMinutes: 2,
useAutoSaveTemporaryFiles: true,
saveVersion: 1,
usePreferencesSave: true,
},
files: { note: "OPFS 存储;本地 .blend 仅通过“下载 .blend”手动导出。" },
};
export interface PreferenceDefinition {
path: string;
tab: PreferenceTab;
label: string;
description?: string;
kind: "boolean" | "number" | "text" | "select" | "readonly";
min?: number;
max?: number;
step?: number;
options?: Array<{ value: string; label: string }>;
supported: boolean;
desktopSource: string;
}
export const PREFERENCE_DEFINITIONS: PreferenceDefinition[] = [
{ path: "interface.showStatus", tab: "interface", label: "显示状态栏", kind: "boolean", supported: true, desktopSource: "preferences.view.show_statusbar_stats" },
{ path: "interface.quickTools", tab: "interface", label: "显示 Web 快捷操作栏", kind: "boolean", supported: true, desktopSource: "Web 扩展;对应 SpaceView3D 快捷工具" },
{ path: "interface.showAreaSwitcher", tab: "interface", label: "显示区域编辑器切换器", kind: "boolean", supported: true, desktopSource: "Area header space type button" },
{ path: "interface.showObjectInfo", tab: "interface", label: "显示对象信息", kind: "boolean", supported: false, desktopSource: "preferences.view.show_object_info" },
{ path: "interface.showViewName", tab: "interface", label: "显示视图名称", kind: "boolean", supported: false, desktopSource: "preferences.view.show_view_name" },
{ path: "interface.showPlaybackFps", tab: "interface", label: "显示播放 FPS", kind: "boolean", supported: false, desktopSource: "preferences.view.show_playback_fps" },
{ path: "interface.playbackFpsSamples", tab: "interface", label: "FPS 采样数", kind: "number", min: 0, max: 5000, step: 1, supported: false, desktopSource: "preferences.view.playback_fps_samples (8)" },
{ path: "interface.recentFiles", tab: "interface", label: "最近项目数量", kind: "number", min: 0, max: 1000, step: 1, supported: false, desktopSource: "preferences.filepaths.recent_files (200)" },
{ path: "interface.statusbarVersion", tab: "interface", label: "状态栏显示版本", kind: "boolean", supported: true, desktopSource: "preferences.view.show_statusbar_version" },
{ path: "interface.statusbarExtensions", tab: "interface", label: "状态栏显示扩展更新", kind: "boolean", supported: false, desktopSource: "preferences.view.show_extensions_updates" },
{ path: "interface.startupWorkspace", tab: "interface", label: "启动工作区", kind: "select", options: [
{ value: "Layout", label: "Layout" }, { value: "Modeling", label: "Modeling" }, { value: "Shading", label: "Shading" },
{ value: "Rendering", label: "Rendering" }, { value: "Compositing", label: "Compositing" }, { value: "Geometry Nodes", label: "Geometry Nodes" },
{ value: "Animation", label: "Animation" }, { value: "Scripting", label: "Scripting" },
], supported: true, desktopSource: "Web 启动行为;桌面 SpaceLink 恢复语义" },
{ path: "viewport.gizmoSize", tab: "viewport", label: "变换 Gizmo 大小", kind: "number", min: 20, max: 200, step: 1, supported: false, desktopSource: "preferences.view.gizmo_size (75)" },
{ path: "viewport.gizmoSizeNavigate", tab: "viewport", label: "导航 Gizmo 大小", kind: "number", min: 20, max: 200, step: 1, supported: false, desktopSource: "preferences.view.gizmo_size_navigate_v3d (80)" },
{ path: "viewport.miniAxis", tab: "viewport", label: "迷你坐标轴", kind: "select", options: [{ value: "GIZMO", label: "Gizmo" }, { value: "MINIMAL", label: "Minimal" }], supported: false, desktopSource: "preferences.view.mini_axis_type (GIZMO)" },
{ path: "viewport.viewportAA", tab: "viewport", label: "视口抗锯齿", kind: "number", min: 0, max: 32, step: 1, supported: false, desktopSource: "preferences.system.viewport_aa (8)" },
{ path: "viewport.overlaySmoothWire", tab: "viewport", label: "平滑线框", kind: "boolean", supported: false, desktopSource: "preferences.system.use_overlay_smooth_wire" },
{ path: "viewport.navigationSensitivity", tab: "viewport", label: "导航灵敏度", kind: "number", min: 0.2, max: 4, step: 0.1, supported: false, desktopSource: "preferences.inputs.* 导航速度" },
{ path: "viewport.defaultShading", tab: "viewport", label: "默认着色", kind: "select", options: [{ value: "SOLID", label: "Solid" }, { value: "MATERIAL", label: "Material" }, { value: "RENDERED", label: "Rendered" }], supported: false, desktopSource: "SpaceView3D.shading(每视口)" },
{ path: "animation.useAutoKeying", tab: "animation", label: "自动关键帧", kind: "boolean", supported: false, desktopSource: "preferences.edit.use_auto_keying (false)" },
{ path: "animation.autoKeyingMode", tab: "animation", label: "自动关键帧模式", kind: "select", options: [{ value: "ADD_REPLACE_KEYS", label: "Add/Replace" }, { value: "REPLACE_KEYS", label: "Replace" }], supported: false, desktopSource: "preferences.edit.auto_keying_mode" },
{ path: "animation.keyframeInterpolation", tab: "animation", label: "新建关键帧插值", kind: "select", options: [{ value: "BEZIER", label: "Bezier" }, { value: "LINEAR", label: "Linear" }, { value: "CONSTANT", label: "Constant" }], supported: false, desktopSource: "preferences.edit.keyframe_new_interpolation_type (BEZIER)" },
{ path: "animation.keyframeHandle", tab: "animation", label: "新建关键帧手柄", kind: "select", options: [{ value: "AUTO_CLAMPED", label: "Auto Clamped" }, { value: "AUTO", label: "Auto" }, { value: "VECTOR", label: "Vector" }, { value: "ALIGNED", label: "Aligned" }, { value: "FREE", label: "Free" }], supported: false, desktopSource: "preferences.edit.keyframe_new_handle_type (AUTO_CLAMPED)" },
{ path: "navigation.orbitSensitivity", tab: "input", label: "轨道旋转灵敏度", kind: "number", min: 0.2, max: 4, step: 0.1, supported: false, desktopSource: "preferences.inputs.ndof/view rotation speed" },
{ path: "navigation.zoomSensitivity", tab: "input", label: "滚轮缩放灵敏度", kind: "number", min: 0.2, max: 4, step: 0.1, supported: false, desktopSource: "preferences.inputs.view_zoom" },
{ path: "navigation.invertZoom", tab: "input", label: "反转滚轮缩放", kind: "boolean", supported: false, desktopSource: "preferences.inputs.invert_zoom" },
{ path: "keymap.activeKeyconfig", tab: "keymap", label: "键位配置", kind: "text", supported: false, desktopSource: "preferences.keymap.active_keyconfig" },
{ path: "saveLoad.autoSaveTimeMinutes", tab: "saveLoad", label: "自动保存间隔(分钟)", kind: "number", min: 1, max: 60, step: 1, supported: false, desktopSource: "preferences.filepaths.auto_save_time (2)" },
{ path: "saveLoad.useAutoSaveTemporaryFiles", tab: "saveLoad", label: "自动保存临时文件", kind: "boolean", supported: false, desktopSource: "preferences.filepaths.use_auto_save_temporary_files" },
{ path: "saveLoad.saveVersion", tab: "saveLoad", label: "保存版本数", kind: "number", min: 0, max: 32, step: 1, supported: false, desktopSource: "preferences.filepaths.save_version (1)" },
{ path: "saveLoad.usePreferencesSave", tab: "saveLoad", label: "自动保存偏好设置", kind: "boolean", supported: true, desktopSource: "preferences.use_preferences_save (true)" },
{ path: "themes.note", tab: "themes", label: "主题", kind: "readonly", supported: false, desktopSource: "preferences.themes;Web 当前固定 dark" },
{ path: "system.note", tab: "system", label: "系统 / 输入设备 / 扩展", kind: "readonly", supported: false, desktopSource: "preferences.system / inputs / addons / extensions" },
];
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function bool(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function number(value: unknown, fallback: number, min: number, max: number): number {
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
}
function text(value: unknown, fallback: string, max = 200): string {
return typeof value === "string" && value.length > 0 && value.length <= max ? value : fallback;
}
export function validateWebPreferences(value: unknown): WebPreferencesIR {
if (!record(value)) return structuredClone(DEFAULT_WEB_PREFERENCES);
const d = DEFAULT_WEB_PREFERENCES;
const i = record(value.interface) ? value.interface : {};
const v = record(value.viewport) ? value.viewport : {};
const a = record(value.animation) ? value.animation : {};
const n = record(value.navigation) ? value.navigation : {};
const k = record(value.keymap) ? value.keymap : {};
const s = record(value.saveLoad) ? value.saveLoad : {};
const bindings: Record<string, string> = {};
if (record(k.bindings)) {
for (const [key, chord] of Object.entries(k.bindings)) {
if (typeof chord === "string" && chord.length <= 80) bindings[key.slice(0, 120)] = chord;
}
}
return {
schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION,
interface: {
showStatus: bool(i.showStatus, d.interface.showStatus),
quickTools: bool(i.quickTools, d.interface.quickTools),
showAreaSwitcher: bool(i.showAreaSwitcher, d.interface.showAreaSwitcher),
showObjectInfo: bool(i.showObjectInfo, d.interface.showObjectInfo),
showViewName: bool(i.showViewName, d.interface.showViewName),
showPlaybackFps: bool(i.showPlaybackFps, d.interface.showPlaybackFps),
playbackFpsSamples: number(i.playbackFpsSamples, d.interface.playbackFpsSamples, 0, 5000),
recentFiles: number(i.recentFiles, d.interface.recentFiles, 0, 1000),
statusbarVersion: bool(i.statusbarVersion, d.interface.statusbarVersion),
statusbarExtensions: bool(i.statusbarExtensions, d.interface.statusbarExtensions),
startupWorkspace: text(i.startupWorkspace, d.interface.startupWorkspace, 40),
},
viewport: {
gizmoSize: number(v.gizmoSize, d.viewport.gizmoSize, 20, 200),
gizmoSizeNavigate: number(v.gizmoSizeNavigate, d.viewport.gizmoSizeNavigate, 20, 200),
miniAxis: v.miniAxis === "MINIMAL" ? "MINIMAL" : "GIZMO",
viewportAA: number(v.viewportAA, d.viewport.viewportAA, 0, 32),
overlaySmoothWire: bool(v.overlaySmoothWire, d.viewport.overlaySmoothWire),
navigationSensitivity: number(v.navigationSensitivity, d.viewport.navigationSensitivity, 0.2, 4),
defaultShading: v.defaultShading === "SOLID" || v.defaultShading === "RENDERED" ? v.defaultShading : "MATERIAL",
},
animation: {
useAutoKeying: bool(a.useAutoKeying, d.animation.useAutoKeying),
autoKeyingMode: a.autoKeyingMode === "REPLACE_KEYS" ? "REPLACE_KEYS" : "ADD_REPLACE_KEYS",
keyframeInterpolation: a.keyframeInterpolation === "LINEAR" || a.keyframeInterpolation === "CONSTANT" ? a.keyframeInterpolation : "BEZIER",
keyframeHandle: ["AUTO", "VECTOR", "ALIGNED", "FREE"].includes(String(a.keyframeHandle)) ? a.keyframeHandle as WebPreferencesIR["animation"]["keyframeHandle"] : "AUTO_CLAMPED",
},
navigation: {
orbitSensitivity: number(n.orbitSensitivity, d.navigation.orbitSensitivity, 0.2, 4),
zoomSensitivity: number(n.zoomSensitivity, d.navigation.zoomSensitivity, 0.2, 4),
invertZoom: bool(n.invertZoom, d.navigation.invertZoom),
},
keymap: { activeKeyconfig: text(k.activeKeyconfig, d.keymap.activeKeyconfig, 80), bindings },
saveLoad: {
autoSaveTimeMinutes: number(s.autoSaveTimeMinutes, d.saveLoad.autoSaveTimeMinutes, 1, 60),
useAutoSaveTemporaryFiles: bool(s.useAutoSaveTemporaryFiles, d.saveLoad.useAutoSaveTemporaryFiles),
saveVersion: number(s.saveVersion, d.saveLoad.saveVersion, 0, 32),
usePreferencesSave: bool(s.usePreferencesSave, d.saveLoad.usePreferencesSave),
},
files: { note: d.files.note },
};
}
export function migrateLegacyPreferences(value: unknown): WebPreferencesIR | null {
if (!record(value)) return null;
const preferences = structuredClone(DEFAULT_WEB_PREFERENCES);
if (typeof value.showStatus === "boolean") preferences.interface.showStatus = value.showStatus;
if (typeof value.quickTools === "boolean") preferences.interface.quickTools = value.quickTools;
if (typeof value.showAreaSwitcher === "boolean") preferences.interface.showAreaSwitcher = value.showAreaSwitcher;
return preferences;
}
export function getPreferencePath(preferences: WebPreferencesIR, path: string): unknown {
return path.split(".").reduce<unknown>((current, key) => record(current) ? current[key] : undefined, preferences);
}
export function setPreferencePath(preferences: WebPreferencesIR, path: string, value: unknown): WebPreferencesIR {
const next = structuredClone(preferences);
const keys = path.split(".");
let target: Record<string, unknown> = next as unknown as Record<string, unknown>;
for (const key of keys.slice(0, -1)) {
if (!record(target[key])) target[key] = {};
target = target[key] as Record<string, unknown>;
}
target[keys[keys.length - 1]] = value;
return next;
}