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; }; 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; areaEditorOverrides: Record; } 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 { 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 = {}; 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((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 = next as unknown as Record; for (const key of keys.slice(0, -1)) { if (!record(target[key])) target[key] = {}; target = target[key] as Record; } target[keys[keys.length - 1]] = value; return next; }