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>
This commit is contained in:
mes123456
2026-11-13 09:50:12 +08:00
parent ebf1e8e873
commit 371427a6bc
18 changed files with 801 additions and 40 deletions

View File

@@ -109,7 +109,8 @@ import { exportGLB } from "../../../protocol/glb-export";
import {RobotTool} from '../robot/RobotTool';
import {DesktopMenu,DesktopDialog,type DesktopMenuItem} from './DesktopMenus';
import { AutosaveRecoveryDialog, type RecoveryPointSelection } from './AutosaveRecoveryDialog';
import type {BlenderViewportCamera,ViewportNavigationCommand} from '../../../protocol/viewport-camera';
import {eulerFromQuaternion,type BlenderViewportCamera,type ViewportNavigationCommand} from '../../../protocol/viewport-camera';
import {sceneCameraFrame} from '../three-adapter/scene-camera';
import { mapEvaluatedNonMeshForExport } from "../../../protocol/nonmesh-export";
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
import { applyCurveGizmoDelta, curveGizmoAxisDelta, deriveCurveHandleGizmoFrame, type CurveGizmoFrameIR, type CurveGizmoHandleIR, type CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
@@ -138,7 +139,8 @@ import {
} from "../volume/nanovdb-viewport";
import { composePaintColorPatch, composePaintWeightPatch, createSculptMeshAttributes, createSculptStroke } from "../../../protocol/paint";
import type { SculptBrush, SculptStrokeSampleIR } from "../../../protocol/sculpt";
import { BLENDER_WORKSPACE_IDS, createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import { BLENDER_WORKSPACE_IDS, createDefaultWebWorkspaceState, reduceUICommand, type AreaIR, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import { applyAreaOperation, findAreaNeighbor, measureAreaRects, type AreaDirection, type AreaOperation, type AreaSplitDirection } from "../../../protocol/area-layout";
import { EDITOR_MODES, parseEditorWorkflow, type EditorWorkflowIR, type EditorAreaIR, type EditorContextEdit, type NativeTimelineEdit, type NativeUVEdit } from "../../../protocol/editor-workflow";
import { importExternalVFontIntoMain } from "../fonts/external-vfont-import";
import { createIMECompositionState, reduceIMEComposition, shouldBlockOperatorShortcuts, type IMECompositionEvent } from "../../../protocol/ime-composition";
@@ -208,7 +210,7 @@ function sceneCameraPose(snapshot: SceneSnapshotIR | null): BlenderViewportCamer
w = (m10 - m01) / s; x = (m02 + m20) / s; y = (m12 + m21) / s; z = 0.25 * s;
}
const fov = camera.projection === "ORTHOGRAPHIC" ? 45 : Math.min(170, Math.max(5, 2 * Math.atan(18 / Math.max(1, camera.lensMm)) * 180 / Math.PI));
return { position, target, orientation: [w, x, y, z], projection: camera.projection === "ORTHOGRAPHIC" ? "ORTHOGRAPHIC" : "PERSPECTIVE", fov, near: Math.max(0.0001, camera.near), far: Math.max(camera.near + 0.001, camera.far), zoom: 1, orthoHeight: Math.max(0.001, camera.orthoScale ?? 5) };
return { position, target, orientation: [x, y, z, w], projection: camera.projection === "ORTHOGRAPHIC" ? "ORTHOGRAPHIC" : "PERSPECTIVE", fov, near: Math.max(0.0001, camera.near), far: Math.max(camera.near + 0.001, camera.far), zoom: 1, orthoHeight: Math.max(0.001, camera.orthoScale ?? 5) };
}
const IO_FORMAT_UI_REGISTRY = parseIOFormatUIRegistry(ioFormatUIRegistryJSON);
@@ -1214,7 +1216,8 @@ function rememberLastProjectId(projectId: string): void {
interface AreaProps {
className?: string;
editor: EditorType;
children: React.ReactNode;
children?: React.ReactNode;
areaId?: string;
}
const AREA_EDITOR_CHOICES: EditorType[] = [
@@ -1227,6 +1230,12 @@ interface AreaEditorContextValue {
overrides: Record<string, EditorType>;
setOverride: (areaKey: string, editor: EditorType | null) => void;
render: (editor: EditorType, areaKey: string) => React.ReactNode;
layoutFor: (areaKey: string) => AreaIR | null;
customLayout: boolean;
neighbors: (areaId: string) => Record<AreaDirection, AreaIR | null>;
areas: AreaIR[];
dispatchArea: (areaId: string, operation: AreaOperation) => void;
resetLayout: () => void;
}
const AreaEditorContext = createContext<AreaEditorContextValue | null>(null);
@@ -1258,23 +1267,71 @@ const EDITOR_ICONS: Record<EditorType, string> = {
};
function Area({ className = "", editor, children }: AreaProps) {
function Area({ className = "", editor, children, areaId }: AreaProps) {
const context = useContext(AreaEditorContext);
const areaKey = className || editor;
const override = className ? context?.overrides[areaKey] : undefined;
const activeEditor = override ?? editor;
const areaKey = areaId ?? (className || editor);
const layout = context?.layoutFor(areaKey) ?? null;
const activeDefault = layout?.editor ?? editor;
const override = className ? context?.overrides[className] : context?.overrides[areaKey];
const activeEditor = override ?? activeDefault;
const custom = Boolean(context?.customLayout);
if (custom && !layout) return null;
if (custom && layout && !layout.maximized && context?.areas.some((area) => area.maximized)) return null;
const rect = layout?.rect;
const style = custom && layout?.maximized ? { left: "0%", top: "0%", width: "100%", height: "100%" } : custom && rect ? {
left: `${rect.x * 100}%`,
top: `${(1 - rect.y - rect.height) * 100}%`,
width: `${rect.width * 100}%`,
height: `${rect.height * 100}%`,
} : undefined;
const targetId = layout?.id ?? areaKey;
const neighbors = context?.neighbors(targetId) ?? { LEFT: null, RIGHT: null, UP: null, DOWN: null };
const directionLabel: Record<AreaDirection, string> = { LEFT: "左", RIGHT: "右", UP: "上", DOWN: "下" };
const areaMenuItems: DesktopMenuItem[] = context ? [
{ label: "水平分割(上下)", action: () => context.dispatchArea(targetId, { type: "SPLIT_AREA", areaId: targetId, direction: "HORIZONTAL" as AreaSplitDirection, factor: 0.5 }) },
{ label: "垂直分割(左右)", action: () => context.dispatchArea(targetId, { type: "SPLIT_AREA", areaId: targetId, direction: "VERTICAL" as AreaSplitDirection, factor: 0.5 }) },
{ label: "", separator: true },
{ label: "合并 / 停靠", children: (Object.keys(directionLabel) as AreaDirection[]).map((direction) => ({
label: `合并到${directionLabel[direction]}侧`,
disabled: !neighbors[direction],
reason: "该方向没有相邻区域",
action: () => { const neighbor = neighbors[direction]; if (neighbor) context.dispatchArea(targetId, { type: "JOIN_AREA", areaId: targetId, targetAreaId: neighbor.id }); },
})) },
{ label: "交换编辑器", children: context.areas.filter((area) => area.id !== targetId).map((area) => ({
label: `${area.editor} · ${area.id}`,
action: () => context.dispatchArea(targetId, { type: "SWAP_AREAS", areaId: targetId, targetAreaId: area.id }),
})) },
{ label: "复制区域", action: () => context.dispatchArea(targetId, { type: "DUPLICATE_AREA", areaId: targetId }) },
{ label: "", separator: true },
{ label: layout?.maximized ? "还原大小" : "最大化区域", action: () => context.dispatchArea(targetId, { type: "TOGGLE_MAXIMIZE_AREA", areaId: targetId }) },
{ label: "聚焦模式", action: () => context.dispatchArea(targetId, { type: "TOGGLE_FULLSCREEN_AREA", areaId: targetId }) },
{ label: "关闭区域", disabled: context.areas.length <= 1, reason: "至少保留一个区域", action: () => context.dispatchArea(targetId, { type: "CLOSE_AREA", areaId: targetId }) },
{ label: "", separator: true },
{ label: "重置工作区布局", action: () => context.resetLayout() },
] : [];
const newAreaContent = children === undefined && context ? context.render(activeEditor, areaKey) : children;
const useDefaultContent = override ? false : layout ? (layout.editor === editor && children !== undefined) : children !== undefined;
return (
<section className={`area-frame ${className}`} data-editor={activeEditor} data-default-editor={editor}>
{className && context ? (
<label className="area-type-switch" title="切换该区域的编辑器类型">
<section
className={`area-frame ${className}`}
data-editor={activeEditor}
data-default-editor={editor}
data-web-area-id={targetId}
data-content-key={className || undefined}
style={style}
>
{(className || areaId) && context ? (
<div className="area-type-switch" title="切换该区域的编辑器类型">
<span aria-hidden="true">{EDITOR_ICONS[activeEditor]}</span>
<select aria-label={`切换 ${editor} 编辑器`} value={activeEditor} onChange={(event) => {
const next = event.target.value as EditorType;
context.setOverride(areaKey, next === editor ? null : next);
if (custom) context.dispatchArea(targetId, { type: "SET_AREA_EDITOR", areaId: targetId, editor: next });
else context.setOverride(className, next === editor ? null : next);
}}>
{AREA_EDITOR_CHOICES.map((choice) => <option key={choice} value={choice}>{choice}{choice === editor ? "(默认)" : ""}</option>)}
</select>
</label>
<DesktopMenu className="area-menu" label="⋮" items={areaMenuItems} />
</div>
) : null}
<header className="editor-header">
<button className="editor-selector" type="button" aria-label={`切换 ${editor} 编辑器`}>
@@ -1284,7 +1341,7 @@ function Area({ className = "", editor, children }: AreaProps) {
<span className="header-spacer" />
<button className="icon-button" type="button" aria-label={`${editor} 菜单`}>⋮</button>
</header>
<div className="area-content">{override && context ? context.render(override, areaKey) : children}</div>
<div className="area-content">{useDefaultContent ? newAreaContent : context!.render(activeEditor, areaKey)}</div>
</section>
);
}
@@ -1355,7 +1412,7 @@ function sameGreasePencilDrawing(left: GreasePencilDrawingScopeIR | null | undef
left.frame === right.frame && left.drawingId === right.drawingId);
}
function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers, textureAssets, volumeProject, lodLevels, selectedObjectIds, editMode, meshSelection, greasePencilSelectionRevision, cameraPose, onSelect, onElementSelect, onGreasePencilPointSelect, onGreasePencilMarqueeSelect, onTransform, onDiagnostic }: {
function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers, textureAssets, volumeProject, lodLevels, selectedObjectIds, editMode, meshSelection, greasePencilSelectionRevision, cameraPose, cameraLocked, showViewName, onSelect, onElementSelect, onGreasePencilPointSelect, onGreasePencilMarqueeSelect, onTransform, onCameraWrite, onCameraLockChange, onDiagnostic }: {
snapshot: SceneSnapshotIR | null;
geometryBuffers: MeshGeometryBuffer[];
nonMeshGeometryBuffers: NonMeshGeometryChunk[];
@@ -1367,6 +1424,10 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
meshSelection: MeshEditSelection;
greasePencilSelectionRevision: number;
cameraPose?: BlenderViewportCamera | null;
cameraLocked?: boolean;
showViewName?: boolean;
onCameraWrite?: (pose: BlenderViewportCamera & { objectId: string }) => void;
onCameraLockChange?: (locked: boolean) => void;
onSelect: (id: string, additive: boolean) => void;
onElementSelect: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
onGreasePencilPointSelect: (point: GreasePencilPointRef, mode: "REPLACE" | "ADD" | "SUBTRACT", baseSelectionRevision: number) => void;
@@ -1377,6 +1438,10 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
const canvasRef = useRef<HTMLCanvasElement>(null);
const rendererRef = useRef<ViewportBackend | null>(null);
const onSelectRef = useRef(onSelect);
const onCameraWriteRef = useRef(onCameraWrite);
const onCameraLockChangeRef = useRef(onCameraLockChange);
onCameraWriteRef.current = onCameraWrite;
onCameraLockChangeRef.current = onCameraLockChange;
const onElementSelectRef = useRef(onElementSelect);
const onGreasePencilPointSelectRef = useRef(onGreasePencilPointSelect);
const onGreasePencilMarqueeSelectRef = useRef(onGreasePencilMarqueeSelect);
@@ -1518,6 +1583,7 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
? acquireOffscreenViewportRenderer(canvas, selectObject, selectElement, selectGreasePencilPoint, selectGreasePencilMarquee)
: new ViewportRenderer(canvas, selectObject, selectElement, selectGreasePencilPoint, selectGreasePencilMarquee);
rendererRef.current = renderer;
renderer.setCameraWriteHandler?.((pose) => onCameraWriteRef.current?.(pose));
return () => {
rendererRef.current = null;
if (renderer instanceof OffscreenViewportRenderer) releaseOffscreenViewportRenderer(canvas, renderer);
@@ -1529,6 +1595,14 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
}
}, []);
useEffect(() => {
const renderer = rendererRef.current;
renderer?.setSceneCamera?.(sceneCameraFrame(snapshot));
}, [snapshot]);
useEffect(() => {
rendererRef.current?.setCameraLock?.(Boolean(cameraLocked));
}, [cameraLocked]);
useEffect(() => {
const renderer = rendererRef.current;
renderer?.setSnapshot(snapshot ?? {
@@ -1657,7 +1731,7 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
return (
<div className="viewport-placeholder" role="region" aria-label="Three.js 视口区域">
<canvas ref={canvasRef} className="viewport-canvas" aria-label="Three.js WebGL2 视口" />
<div className="viewport-view-label">{({USER:'用户',FRONT:'前',BACK:'后',LEFT:'左',RIGHT:'右',TOP:'顶',BOTTOM:'底'} as Record<string,string>)[navigationView.view]??navigationView.view} · {navigationView.projection==='ORTHOGRAPHIC'?'正交':'透视'}</div>
<div className="viewport-view-label">{(showViewName===false?null:({USER:'用户',CAMERA:'相机',FRONT:'前',BACK:'后',LEFT:'左',RIGHT:'右',TOP:'顶',BOTTOM:'底'} as Record<string,string>)[navigationView.view]??navigationView.view)} · {navigationView.projection==='ORTHOGRAPHIC'?'正交':'透视'}</div>
<div className="axis-gizmo" role="group" aria-label="视图方向">
{(['X','Y','Z'] as const).flatMap((axis,index)=>[1,-1].map(sign=>({axis,index,sign,depth:(navigationView.axes[index]?.[2]??0)*sign}))).sort((a,b)=>b.depth-a.depth).filter((item,index,all)=>{
const direction=navigationView.axes[item.index]??[0,0,0];
@@ -1673,6 +1747,9 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
<button type="button" aria-label="放大视图" title="放大视图" onClick={()=>navigate('ZOOM_IN')}>+</button><button type="button" aria-label="缩小视图" title="缩小视图" onClick={()=>navigate('ZOOM_OUT')}>−</button>
<button type="button" aria-label="显示全部" title="显示全部 (Home)" onClick={()=>navigate('FRAME_ALL')}>⌂</button><button type="button" aria-label="框显所选" title="框显所选 (小键盘 .)" onClick={()=>navigate('FRAME_SELECTED')}>⊙</button>
<button type="button" aria-label="切换透视正交" title="透视 / 正交 (小键盘 5)" aria-pressed={navigationView.projection==='ORTHOGRAPHIC'} onClick={()=>navigate('PROJECTION')}>▱</button>
<button type="button" aria-label="相机视图" title="相机视图 (小键盘 0)" aria-pressed={navigationView.view==='CAMERA'} onClick={()=>navigate('CAMERA')}>◎</button>
<button type="button" aria-label="相机对齐视图" title="对齐相机到视图 (Ctrl+Alt+小键盘 0)" onClick={()=>navigate('CAMERA_TO_VIEW')}>⌾</button>
<button type="button" aria-label="锁定相机" title="Camera to View(锁定相机到视图)" aria-pressed={Boolean(cameraLocked)} onClick={()=>{const next=!cameraLocked;onCameraLockChangeRef.current?.(next);rendererRef.current?.setCameraLock?.(next);}}>🔒</button>
</div>
{viewportError ? <div className="viewport-message"><strong>3D Viewport</strong><span>WebGL 不可用,已保留场景编辑界面:{viewportError}</span></div> : null}
{editMode && activeTool === "marquee" && marqueeDrawing ? <div className="grease-pencil-marquee-surface" data-testid="grease-pencil-marquee-surface" data-drawing-id={marqueeDrawing.drawingId} onPointerDown={beginMarquee} onPointerMove={moveMarquee} onPointerUp={finishMarquee} onPointerCancel={finishMarquee}>{marqueeBox ? <span className="grease-pencil-marquee-box" style={{ left: `${marqueeBox.left * 100}%`, top: `${marqueeBox.top * 100}%`, width: `${(marqueeBox.right - marqueeBox.left) * 100}%`, height: `${(marqueeBox.bottom - marqueeBox.top) * 100}%` }} /> : null}</div> : null}
@@ -4023,7 +4100,9 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
const [preferencesStatus,setPreferencesStatus]=useState('');
const [settingsDirty,setSettingsDirty]=useState(false);
const [areaEditorOverrides,setAreaEditorOverrides]=useState<Record<string,EditorType>>(() => initialWebSettings?.areaEditorOverrides ?? {});
const settingsRef = useRef<WebSettingsFile>(initialWebSettings ?? { schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION, updatedAt: new Date().toISOString(), preferences: structuredClone(DEFAULT_WEB_PREFERENCES), areaLayouts: {}, areaEditorOverrides: {} });
const [cameraLocks,setCameraLocks]=useState<Record<string,boolean>>({});
const [areaLayouts,setAreaLayouts]=useState<Record<string,AreaIR[]>>({});
const settingsRef = useRef<WebSettingsFile>(initialWebSettings ?? { schemaVersion: WEB_PREFERENCES_SCHEMA_VERSION, updatedAt: new Date().toISOString(), preferences: structuredClone(DEFAULT_WEB_PREFERENCES), areaLayouts: {}, areaEditorOverrides: {}, cameraLocks: {} });
const settingsLoadedRef = useRef(false);
useEffect(() => {
let cancelled = false;
@@ -4036,6 +4115,10 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
setShowStatus(settings.preferences.interface.showStatus);
setShowAreaSwitcher(settings.preferences.interface.showAreaSwitcher);
setAreaEditorOverrides(settings.areaEditorOverrides);
setCameraLocks(settings.cameraLocks ?? {});
if (Object.keys(settings.areaLayouts).length > 0) {
setAreaLayouts(Object.fromEntries(Object.entries(settings.areaLayouts).map(([workspaceId, layout]) => [workspaceId, layout.areas.map((area) => ({ id: area.id, editor: area.editor as EditorType, contentKey: area.contentKey, regions: [], rect: area.rect, maximized: false, view3d: area.view3d }))])));
}
setPreferencesError("");
}).catch((error: unknown) => {
if (!cancelled) { settingsLoadedRef.current = true; setPreferencesError(String(error)); }
@@ -4047,10 +4130,15 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
...webPreferences,
interface: { ...webPreferences.interface, showStatus, quickTools, showAreaSwitcher },
};
const merged: WebSettingsFile = { ...settingsRef.current, preferences: mergedPreferences, areaEditorOverrides };
const layoutRecords = Object.fromEntries(Object.entries(areaLayouts).map(([workspaceId, areas]) => [workspaceId, {
layoutMode: "custom" as const,
areas: areas.map((area) => ({ id: area.id, editor: area.editor, contentKey: area.contentKey ?? null, rect: area.rect, view3d: area.view3d })),
activeAreaId: uiState.workspaces[workspaceId as WorkspaceId]?.activeAreaId ?? areas[0]?.id ?? "",
}]));
const merged: WebSettingsFile = { ...settingsRef.current, preferences: mergedPreferences, areaEditorOverrides, areaLayouts: layoutRecords, cameraLocks };
settingsRef.current = merged;
if (settingsLoadedRef.current && mergedPreferences.saveLoad.usePreferencesSave) scheduleWebSettingsSave(merged);
}, [webPreferences, quickTools, showStatus, showAreaSwitcher, areaEditorOverrides]);
}, [webPreferences, quickTools, showStatus, showAreaSwitcher, areaEditorOverrides, areaLayouts, cameraLocks, uiState.workspaces]);
useEffect(() => {
const flush = (): void => { void flushWebSettingsSave(); };
const hide = (): void => { if (document.visibilityState === "hidden") flush(); };
@@ -4067,10 +4155,10 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
setPreferencesStatus("");
}, []);
const savePreferencesNow = useCallback(async (): Promise<void> => {
const saved = await saveWebSettings({ ...settingsRef.current, preferences: { ...settingsRef.current.preferences, interface: { ...settingsRef.current.preferences.interface, showStatus, quickTools, showAreaSwitcher } }, areaEditorOverrides });
const saved = await saveWebSettings({ ...settingsRef.current, preferences: { ...settingsRef.current.preferences, interface: { ...settingsRef.current.preferences.interface, showStatus, quickTools, showAreaSwitcher } }, areaEditorOverrides, cameraLocks });
setSettingsDirty(false);
setPreferencesStatus(saved ? "已保存到 OPFS" : "已缓存(OPFS 不可用)");
}, [quickTools, showStatus, showAreaSwitcher, areaEditorOverrides]);
}, [quickTools, showStatus, showAreaSwitcher, areaEditorOverrides, cameraLocks]);
const bindKeymap = useCallback((actionId: string, chord: string): string | null => {
const bindings = webPreferences.keymap.bindings;
const validation = validateKeymapBinding(actionId, chord, bindings);
@@ -5203,6 +5291,12 @@ export function App({localOnly=false,course}:{localOnly?:boolean;course?:Availab
editCommandTailRef.current = result.then(() => undefined, () => undefined).then(() => { if (client === webClientRef.current && project === projectIdRef.current && actionSequence === userActionSequenceRef.current) setAutosaveEditEpoch(value => value + 1); });
return result;
};
const handleCameraWrite = useCallback((pose: BlenderViewportCamera & { objectId: string }): void => {
const node = snapshot?.nodes.find((candidate) => candidate.id === pose.objectId);
if (!node || node.sourceLibrary) return;
const rotationEuler = eulerFromQuaternion(pose.orientation);
void applyEditCommand({ type: "setObjectTransform", objectId: pose.objectId, translation: pose.position, rotationEuler, scale: node.transform.scale });
}, [snapshot, applyEditCommand]);
const executeEditCommand = async (command: WebEngineEditCommand, onApplied?: (snapshot: SceneSnapshotIR) => void, onFeedback?: (message: string) => void): Promise<boolean> => {
const reportProject = projectIdRef.current, reportRevision = projectRevisionRef.current;
if (projectActionMutexRef.current.owner) {
@@ -8278,6 +8372,49 @@ else:
renderPbr: requestPbrRender,
downloadPbrRender: downloadPbrRenderForTutorial,
};
const workspaceAreaLayout = areaLayouts[uiState.context.workspaceId] ?? null;
const measureCurrentWorkspaceAreas = useCallback((): AreaIR[] => {
const grid = document.querySelector<HTMLElement>(".workspace-grid");
if (!grid) return [];
const frames = Array.from(grid.querySelectorAll<HTMLElement>(":scope > .area-frame"))
.filter((frame) => frame.getClientRects().length > 0 && frame.offsetWidth > 0 && frame.offsetHeight > 0);
const gridRect = grid.getBoundingClientRect();
return measureAreaRects(frames.map((frame) => ({
id: frame.dataset.contentKey || frame.dataset.webAreaId || "",
editor: (frame.dataset.editor || "3D Viewport") as EditorType,
contentKey: frame.dataset.contentKey || null,
rect: frame.getBoundingClientRect(),
})).filter((entry) => entry.id.length > 0), gridRect);
}, []);
const dispatchArea = useCallback((areaId: string, operation: AreaOperation): void => {
if (localOnly) return;
const workspaceId = uiState.context.workspaceId;
const current = areaLayouts[workspaceId] ?? measureCurrentWorkspaceAreas();
const workspace = { ...uiState.workspaces[workspaceId], areas: current, activeAreaId: areaId, layoutMode: "custom" as const };
const next = applyAreaOperation(workspace, operation);
if (next.layoutMode === "default") {
setAreaLayouts((layouts) => { const copy = { ...layouts }; delete copy[workspaceId]; return copy; });
return;
}
setAreaLayouts((layouts) => ({ ...layouts, [workspaceId]: next.areas }));
}, [localOnly, uiState.context.workspaceId, uiState.workspaces, areaLayouts, measureCurrentWorkspaceAreas]);
const resetAreaLayout = useCallback((): void => {
const workspaceId = uiState.context.workspaceId;
setAreaLayouts((layouts) => { const copy = { ...layouts }; delete copy[workspaceId]; return copy; });
}, [uiState.context.workspaceId]);
const areaLayoutFor = useCallback((areaKey: string): AreaIR | null => {
if (!workspaceAreaLayout) return null;
return workspaceAreaLayout.find((area) => area.id === areaKey || area.contentKey === areaKey) ?? null;
}, [workspaceAreaLayout]);
const areaNeighbors = useCallback((areaId: string): Record<AreaDirection, AreaIR | null> => {
const areas = workspaceAreaLayout ?? [];
return {
LEFT: findAreaNeighbor(areas, areaId, "LEFT"),
RIGHT: findAreaNeighbor(areas, areaId, "RIGHT"),
UP: findAreaNeighbor(areas, areaId, "UP"),
DOWN: findAreaNeighbor(areas, areaId, "DOWN"),
};
}, [workspaceAreaLayout]);
const renderAreaEditor = (editor: EditorType, areaKey: string): React.ReactNode => {
switch (editor) {
case "3D Viewport":
@@ -8321,7 +8458,7 @@ else:
const activeMeshSummary = snapshot?.meshes.find((mesh) => mesh.id === activeObjectSummary?.dataId);
const activeMaterialId = activeMeshSummary?.materialSlotIds?.[activeMaterialSlot] ?? snapshot?.materials[0]?.id;
return (
<main className="blender-app" data-native-session-exit={nativeSessionExit} data-native-canvas={nativeCanvasReady && gpuSurfaceOpen ? "true" : undefined} data-quick-tools={quickTools} data-show-area-switcher={showAreaSwitcher} data-hide-status={!showStatus} data-workspace={workspace} data-editor-workflow-status={snapshot?.editorWorkflowStatus ?? "BLOCKED"} data-editor-workflow-workspace={editorWorkflow?.context.workspaceId} data-editor-workflow-area={editorWorkflow?.context.activeAreaId} data-editor-workflow-revision={editorWorkflow?.context.revision} data-ime-composing={imeComposition.composing ? "true" : "false"} data-ime-revision={imeComposition.revision} data-ime-last-event={imeComposition.lastEvent} data-key-key={keyboardObservation?.key} data-key-code={keyboardObservation?.code} data-key-location={keyboardObservation?.location} data-key-modifiers={`${keyboardObservation?.shiftKey ? "S" : ""}${keyboardObservation?.ctrlKey ? "C" : ""}${keyboardObservation?.altKey ? "A" : ""}${keyboardObservation?.metaKey ? "M" : ""}`} data-key-dead={keyboardObservation?.deadKey ? "true" : "false"} data-ui-revision={uiState.context.revision}
<main className="blender-app" data-native-session-exit={nativeSessionExit} data-native-canvas={nativeCanvasReady && gpuSurfaceOpen ? "true" : undefined} data-quick-tools={quickTools} data-show-area-switcher={showAreaSwitcher} data-area-layout={workspaceAreaLayout ? "custom" : "default"} data-hide-status={!showStatus} data-workspace={workspace} data-editor-workflow-status={snapshot?.editorWorkflowStatus ?? "BLOCKED"} data-editor-workflow-workspace={editorWorkflow?.context.workspaceId} data-editor-workflow-area={editorWorkflow?.context.activeAreaId} data-editor-workflow-revision={editorWorkflow?.context.revision} data-ime-composing={imeComposition.composing ? "true" : "false"} data-ime-revision={imeComposition.revision} data-ime-last-event={imeComposition.lastEvent} data-key-key={keyboardObservation?.key} data-key-code={keyboardObservation?.code} data-key-location={keyboardObservation?.location} data-key-modifiers={`${keyboardObservation?.shiftKey ? "S" : ""}${keyboardObservation?.ctrlKey ? "C" : ""}${keyboardObservation?.altKey ? "A" : ""}${keyboardObservation?.metaKey ? "M" : ""}`} data-key-dead={keyboardObservation?.deadKey ? "true" : "false"} data-ui-revision={uiState.context.revision}
data-user-action-import-status={userActions.IMPORT.status} data-user-action-import-id={userActions.IMPORT.identity?.actionId} data-user-action-import-error={userActions.IMPORT.errorCode ?? undefined}
aria-busy={projectActionLocked}
data-user-action-open-status={userActions.OPEN.status} data-user-action-open-id={userActions.OPEN.identity?.actionId} data-user-action-open-error={userActions.OPEN.errorCode ?? undefined}
@@ -8355,7 +8492,7 @@ else:
data-grease-pencil-selection-source={greasePencilSelection?.lastSource ?? "NONE"}
data-grease-pencil-selection-drawing-id={greasePencilSelection?.drawing.drawingId ?? ""}
data-diagnostic-count={diagnostics.length} data-last-diagnostic-code={diagnostics.at(-1)?.code} data-last-diagnostic-detail={diagnostics.at(-1)?.detail?.slice(0, 1200)}>
<AreaEditorContext.Provider value={{ overrides: areaEditorOverrides, setOverride: (areaKey, editor) => setAreaEditorOverrides(current => { const next = { ...current }; if (editor === null) delete next[areaKey]; else next[areaKey] = editor; return next; }), render: renderAreaEditor }}>
<AreaEditorContext.Provider value={{ overrides: areaEditorOverrides, setOverride: (areaKey, editor) => setAreaEditorOverrides(current => { const next = { ...current }; if (editor === null) delete next[areaKey]; else next[areaKey] = editor; return next; }), render: renderAreaEditor, layoutFor: areaLayoutFor, customLayout: Boolean(workspaceAreaLayout), neighbors: areaNeighbors, areas: workspaceAreaLayout ?? [], dispatchArea, resetLayout: resetAreaLayout }}>
{nativeRecoveryWarning ? <aside role="alert" aria-label="恢复存储提示" style={{ position: "fixed", right: 12, bottom: 32, zIndex: 10000, maxWidth: 560, padding: 12, background: "#332b16", color: "#ffe4a3", border: "1px solid #a88635" }}><span>{nativeRecoveryWarning}</span><button type="button" aria-label="关闭恢复提示" onClick={() => setNativeRecoveryWarning(null)}>关闭提示</button></aside> : null}
<header className="topbar">
<div className="brand" title="Blender 5.2.0 LTS Web"><span className="brand-mark" aria-hidden="true"><span className="brand-orbit" /></span><span className="brand-name">Blender</span></div>
@@ -8427,12 +8564,12 @@ else:
onRepair={repairAssetLibrary}
/>}
</Area> : null}
{!localOnly&&workspace === "Animation" ? <Area className="camera-view-area" editor="3D Viewport"><ViewportPlaceholder snapshot={snapshot} geometryBuffers={geometryBuffers} nonMeshGeometryBuffers={nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} greasePencilSelectionRevision={greasePencilSelection?.revision ?? 0} cameraPose={sceneCameraPose(snapshot)} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onGreasePencilMarqueeSelect={selectGreasePencilMarquee} onTransform={transformActive} onDiagnostic={recordDiagnostic} /></Area> : null}
{!localOnly&&workspace === "Animation" ? <Area className="camera-view-area" editor="3D Viewport"><ViewportPlaceholder snapshot={snapshot} geometryBuffers={geometryBuffers} nonMeshGeometryBuffers={nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} greasePencilSelectionRevision={greasePencilSelection?.revision ?? 0} cameraPose={sceneCameraPose(snapshot)} cameraLocked={Boolean(cameraLocks["camera-view-area"])} showViewName={webPreferences.interface.showViewName} onCameraWrite={handleCameraWrite} onCameraLockChange={(locked)=>setCameraLocks(current=>({...current,"camera-view-area":locked}))} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onGreasePencilMarqueeSelect={selectGreasePencilMarquee} onTransform={transformActive} onDiagnostic={recordDiagnostic} /></Area> : null}
{localOnly ? <Area className="spreadsheet-area" editor="Spreadsheet"><WorkspaceEditorSurface editor="Spreadsheet" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : workspace === "Geometry Nodes" ? <Area className="spreadsheet-area" editor="Spreadsheet"><SpreadsheetView snapshot={snapshot} geometryBuffers={geometryBuffers} /></Area> : null}
{scriptingMounted.current ? <ScriptingContainer {...(localOnly?{}:{style:{display:scriptingVisible?"contents":"none"}})}><Area className="text-editor-area" editor="Text Editor">
<WasmTextEditor key={`${projectIdRef.current}:${textEditorEpoch}`} snapshot={snapshot} onCommand={applyEditCommand} projectBusy={projectActionLocked} canEditDraft={()=>!projectActionMutexRef.current.owner} onDraftChanged={dirty=>{if(dirty)textDraftAreas.current.add('web-tools-text');else textDraftAreas.current.delete('web-tools-text');textDraftDirty.current=textDraftAreas.current.size>0;}} onNativeRun={localOnly?undefined:(name,source)=>void runNativeWorkflow({kind:'PYTHON',context:nativeContext(),python:{source,textName:name,console:false}})} onPythonRun={localOnly&&pythonSession?(name,source)=>pythonSession.run(source,name,false):undefined} pythonBusy={pythonState?.status==='running'||pythonState?.status==='stopping'}/>
</Area><Area className="console-area" editor="Python Console">{localOnly?(pythonSession?<WasmPythonConsole session={pythonSession} enabled={Boolean(snapshot)&&!headerPending}/>:null):<NativeSessionPanel key={projectIdRef.current} client={webClientRef.current} context={nativeContext()} onApplied={applyNativeScene}/>}</Area><Area className="info-area" editor="Info"><WorkspaceEditorSurface editor="Info" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area><Area className="secondary-outliner-area" editor="Outliner"><Outliner key={`${projectIdRef.current}:${userActionSequenceRef.current}`} snapshot={snapshot} onToggleExpand={toggleOutlinerExpand} onTreeSelect={selectOutlinerRow} onRemap={(command, feedback) => applyEditCommand(command, undefined, feedback)} onReparent={(ids, parentId, keepTransform) => void applyEditCommand({ type: 'setParents', objectIds: ids, parentId, keepTransform })} onMoveToCollection={(ids, collectionId, keepExistingLinks) => { for (const id of ids) void applyEditCommand({ type: 'moveObjectToCollection', objectId: id, collectionId, keepExistingLinks }); }} onMoveCollection={(collectionId, parentCollectionId) => void applyEditCommand(coreSceneCommands.moveCollection(collectionId, parentCollectionId))} onSelect={selectObject} onRefresh={refreshOutliner} onSelectAll={selectAllOutliner} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area></ScriptingContainer> : null}
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} greasePencilSelectionRevision={greasePencilSelection?.revision ?? 0} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onGreasePencilMarqueeSelect={selectGreasePencilMarquee} onTransform={transformActive} onDiagnostic={recordDiagnostic} /></Area>
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} greasePencilSelectionRevision={greasePencilSelection?.revision ?? 0} cameraLocked={Boolean(cameraLocks["viewport-area"])} showViewName={webPreferences.interface.showViewName} onCameraWrite={handleCameraWrite} onCameraLockChange={(locked)=>setCameraLocks(current=>({...current,"viewport-area":locked}))} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onGreasePencilMarqueeSelect={selectGreasePencilMarquee} onTransform={transformActive} onDiagnostic={recordDiagnostic} /></Area>
<Area className="outliner-area" editor="Outliner"><Outliner key={`${projectIdRef.current}:${userActionSequenceRef.current}`} snapshot={snapshot} onToggleExpand={toggleOutlinerExpand} onTreeSelect={selectOutlinerRow} onRemap={(command, feedback) => applyEditCommand(command, undefined, feedback)} onReparent={(ids, parentId, keepTransform) => void applyEditCommand({ type: 'setParents', objectIds: ids, parentId, keepTransform })} onMoveToCollection={(ids, collectionId, keepExistingLinks) => { for (const id of ids) void applyEditCommand({ type: 'moveObjectToCollection', objectId: id, collectionId, keepExistingLinks }); }} onMoveCollection={(collectionId, parentCollectionId) => void applyEditCommand(coreSceneCommands.moveCollection(collectionId, parentCollectionId))} onSelect={selectObject} onRefresh={refreshOutliner} onSelectAll={selectAllOutliner} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area>
<Area className="properties-area" editor="Properties"><Properties key={mainReplacementEpoch} onSoftBodyCacheOperation={localOnly ? queueSoftBodyCache : undefined} onFluidCacheOperation={localOnly ? (objectId, operation) => queueLocalDesktopOperator("fluid." + operation, {}, true, objectId, "OBJECT") : undefined} pointCacheBusy={headerBusy || Boolean(localInvokePending) || localInvokeActive || nativeModalActive || projectActionLocked} onPointCacheOperation={localOnly ? operation => queueLocalDesktopOperator(operation === "BAKE_ALL" ? "ptcache.bake_all" : "ptcache.free_bake_all", operation === "BAKE_ALL" ? { bake: true } : {}, true) : undefined} selectedEdgeIndices={meshSelection.mode === "EDGE" ? [...meshSelection.indices] : []} nonMeshGeometryBuffers={nonMeshGeometryBuffers} client={webClientRef.current} snapshot={snapshot} editorWorkflow={editorWorkflow} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} greasePencilSelection={greasePencilSelection} onGreasePencilCanvasPointSelect={(point, mode, baseSelectionRevision) => { commitGreasePencilSelection("CANVAS_2D", mode === "ADD" ? "TOGGLE" : mode, [point], baseSelectionRevision, point); }} onCommand={(command) => applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onImportVFont={(file) => void importVFont(file)} onRequestAsset={(assetId) => { const client = webClientRef.current; if (!client) return; void client.requestAsset(assetId).then((asset) => setEngineStatus(`Asset: ${asset.assetId} (${asset.status})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "PBR_ASSET_INVALID", error, { assetId }))); }} onAppendLibraryObject={async (source, request) => {
const client = webClientRef.current; if (!client) return; const appendProjectId = projectIdRef.current; const isCurrent = () => client === webClientRef.current && appendProjectId === projectIdRef.current;
@@ -8443,6 +8580,7 @@ else:
<Area className="timeline-area" editor={workspace === "Animation" ? "Dope Sheet" : "Timeline"}><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} onNativeEdit={localOnly ? applyNativeTimelineEdit : undefined} nativePlaying={nativeTimelinePlaying} playbackAvailable={!localOnly || Boolean(editorWorkflow)} /></Area>
{!localOnly && workspace === "Animation" ? <Area className="graph-editor-area" editor="Graph Editor"><GraphEditor snapshot={snapshot} onCommand={(command) => void applyEditCommand(command)} /></Area> : null}
{localOnly?<Area editor={workspace === 'Animation'?'Timeline':'Dope Sheet'}><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={value=>void applyEditCommand({type:'setFrame',frame:value})} onCommand={command=>void applyEditCommand(command)} onNativeEdit={applyNativeTimelineEdit} nativePlaying={nativeTimelinePlaying} playbackAvailable={Boolean(snapshot && editorWorkflow)}/></Area>:null}
{workspaceAreaLayout ? workspaceAreaLayout.filter((area) => !area.contentKey).map((area) => <Area key={area.id} areaId={area.id} editor={area.editor} />) : null}
</WasmWorkspace> : null}
{autosaveRecovery ? <AutosaveRecoveryDialog initialProjectId={autosaveRecovery.projectId} storage={autosaveRecovery.storage}
projects={[{ projectId: autosaveRecovery.projectId, displayName: autosaveRecovery.displayName }, ...recentProjects.filter(project => project.projectId !== autosaveRecovery.projectId)]}

View File

@@ -241,3 +241,8 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.preferences-window > footer button { padding: 4px 10px; color: #ddd; background: #4775a3; border-radius: 3px; }
.preferences-window > footer span { margin-left: auto; color: #9aa39c; font-size: 11px; }
@media(max-width:700px){.preferences-window{inset:0}.preferences-body{grid-template-columns:110px}.preference-row{grid-template-columns:1fr}}
.blender-app[data-area-layout="custom"] .workspace-grid { position: relative; display: block; background: #0f0f0f; }
.blender-app[data-area-layout="custom"] .workspace-grid > .area-frame { position: absolute; margin: 0; }
.blender-app[data-area-layout="custom"] .workspace-grid > .area-frame::after { position: absolute; inset: 0; border: 1px solid #0f0f0f; pointer-events: none; content: ""; }
.area-menu .desktop-menu-trigger > button, .area-type-switch .area-menu > button { width: 18px; height: 18px; padding: 0; line-height: 1; color: #ddd; background: #3a3a3a; border: 1px solid #505050; border-radius: 2px; font-size: 12px; }
.area-type-switch .desktop-menu-trigger { display: inline-flex; }

View File

@@ -23,6 +23,7 @@ function emptySettings(preferences: WebPreferencesIR = structuredClone(DEFAULT_W
preferences,
areaLayouts: {},
areaEditorOverrides: {},
cameraLocks: {},
};
}
@@ -88,6 +89,9 @@ export function validateWebSettings(value: unknown): WebSettingsFile {
preferences: validateWebPreferences(record.preferences),
areaLayouts: validateLayouts(record.areaLayouts),
areaEditorOverrides: validateEditorOverrides(record.areaEditorOverrides),
cameraLocks: typeof record.cameraLocks === "object" && record.cameraLocks !== null
? Object.fromEntries(Object.entries(record.cameraLocks as Record<string, unknown>).filter(([, value]) => typeof value === "boolean").map(([key, value]) => [key.slice(0, 120), value as boolean]))
: {},
};
}

View File

@@ -35,6 +35,12 @@ export interface ViewportBackend {
setGreasePencilPointPreview(dataId: string, layerId: string, frame: number, points: readonly GreasePencilPointPreview[] | null): void;
setCurveGizmoFrame(dataId: string | null, frame: CurveGizmoFrameIR | null): void;
installLODLevels(meshId: string, levels: readonly WebEngineLODLevelResult[]): void;
setSceneCamera?(frame: import('../../../protocol/viewport-camera').BlenderSceneCameraFrame | null): void;
setCameraWriteHandler?(handler: ((frame: import('../../../protocol/viewport-camera').BlenderViewportCamera & { objectId: string }) => void) | null): void;
toggleCameraView?(): void;
setCameraLock?(locked: boolean): void;
isCameraView?(): boolean;
isCameraLocked?(): boolean;
dispose(): void;
}
@@ -157,6 +163,12 @@ export class OffscreenViewportRenderer implements ViewportBackend {
this.publishInputModal();
}
setSceneCamera(frame: import('../../../protocol/viewport-camera').BlenderSceneCameraFrame | null): void { this.navigation.setSceneCamera(frame); }
setCameraWriteHandler(handler: ((frame: import('../../../protocol/viewport-camera').BlenderViewportCamera & { objectId: string }) => void) | null): void { this.navigation.setCameraWriteHandler(handler); }
toggleCameraView(): void { this.navigation.toggleCameraView(); }
setCameraLock(locked: boolean): void { this.navigation.setCameraLock(locked); }
isCameraView(): boolean { return this.navigation.isCameraView(); }
isCameraLocked(): boolean { return this.navigation.isCameraLocked(); }
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void {
if (snapshot === this.lastSnapshot && geometryBuffers === this.lastGeometryBuffers && nonMeshGeometryBuffers === this.lastNonMeshGeometryBuffers) return;
this.lastSnapshot = snapshot;

View File

@@ -0,0 +1,66 @@
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import type { BlenderSceneCameraFrame } from "../../../protocol/viewport-camera";
function quaternionFromMatrix(m: number[]): [number, number, number, number] {
const m00 = m[0], m01 = m[4], m02 = m[8];
const m10 = m[1], m11 = m[5], m12 = m[9];
const m20 = m[2], m21 = m[6], m22 = m[10];
const trace = m00 + m11 + m22;
let w = 1, x = 0, y = 0, z = 0;
if (trace > 0) {
const s = Math.sqrt(trace + 1) * 2;
w = 0.25 * s; x = (m21 - m12) / s; y = (m02 - m20) / s; z = (m10 - m01) / s;
}
else if (m00 > m11 && m00 > m22) {
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
w = (m21 - m12) / s; x = 0.25 * s; y = (m01 + m10) / s; z = (m02 + m20) / s;
}
else if (m11 > m22) {
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
w = (m02 - m20) / s; x = (m01 + m10) / s; y = 0.25 * s; z = (m12 + m21) / s;
}
else {
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
w = (m10 - m01) / s; x = (m02 + m20) / s; y = (m12 + m21) / s; z = 0.25 * s;
}
return [x, y, z, w];
}
export function sceneCameraFrame(snapshot: SceneSnapshotIR | null): BlenderSceneCameraFrame | null {
const scene = snapshot?.scenes.find((candidate) => candidate.id === snapshot.sceneId);
const node = snapshot?.nodes.find((candidate) => candidate.id === scene?.cameraObjectId);
const camera = snapshot?.cameras.find((candidate) => candidate.id === node?.dataId);
if (!node || !camera) return null;
const m = node.worldMatrix;
if (!Array.isArray(m) || m.length < 16) return null;
const position: [number, number, number] = [m[12], m[13], m[14]];
const forward: [number, number, number] = [-m[8], -m[9], -m[10]];
const length = Math.hypot(forward[0], forward[1], forward[2]) || 1;
const distance = Math.max(1, camera.depthOfField?.focusDistance ?? 8);
const target: [number, number, number] = [
position[0] + forward[0] / length * distance,
position[1] + forward[1] / length * distance,
position[2] + forward[2] / length * distance,
];
const sensor = camera.sensorWidthMm > 0 ? camera.sensorWidthMm : 36;
const fov = camera.projection === "ORTHOGRAPHIC" ? 45 : Math.min(170, Math.max(5, 2 * Math.atan(sensor / (2 * Math.max(1, camera.lensMm))) * 180 / Math.PI));
return {
objectId: node.id,
position,
target,
orientation: quaternionFromMatrix(m),
projection: camera.projection === "ORTHOGRAPHIC" ? "ORTHOGRAPHIC" : "PERSPECTIVE",
fov,
near: Math.max(0.0001, camera.near),
far: Math.max(camera.near + 0.001, camera.far),
zoom: 1,
orthoHeight: Math.max(0.001, camera.orthoScale),
lensMm: camera.lensMm,
sensorWidthMm: sensor,
sensorHeightMm: camera.sensorHeightMm > 0 ? camera.sensorHeightMm : 24,
sensorFit: camera.sensorFit,
shift: [camera.shift[0] ?? 0, camera.shift[1] ?? 0],
orthoScale: camera.orthoScale,
camzoom: 0,
};
}

View File

@@ -2,12 +2,12 @@ import {Box3,Matrix4,MOUSE,OrthographicCamera,PerspectiveCamera,Quaternion,Vecto
import {OrbitControls} from '../vendor/three/addons/controls/OrbitControls.js';
import type {SceneSnapshotIR} from '../../../protocol/scene-ir';
import type {NDOFCamera,NDOFViewportEvent} from '../../../protocol/ndof';
import type {BlenderViewportCamera} from '../../../protocol/viewport-camera';
import {cameraZoomFac,type BlenderSceneCameraFrame,type BlenderViewportCamera} from '../../../protocol/viewport-camera';
import {VIEWPORT_DEFAULT_ORBIT,VIEWPORT_ORBIT_MAX_DISTANCE,VIEWPORT_ORBIT_MIN_DISTANCE,VIEWPORT_ORBIT_ROTATE_SENSITIVITY,VIEWPORT_ORBIT_ZOOM_SENSITIVITY,orbitPosition,orbitStateFromPosition,type ViewportNavigationCamera,type ViewportNavigationCommand,type ViewportView} from '../../../protocol/viewport-camera';
const basis=new Matrix4().set(1,0,0,0, 0,0,1,0, 0,-1,0,0, 0,0,0,1);
const fromDisplay=(v:Vector3):[number,number,number]=>[v.x,-v.z,v.y];
const directions:Record<Exclude<ViewportView,'USER'>,[number,number,number]>={FRONT:[0,0,1],BACK:[0,0,-1],RIGHT:[1,0,0],LEFT:[-1,0,0],TOP:[0,1,1e-7],BOTTOM:[0,-1,1e-7]};
const directions:Record<Exclude<ViewportView,'USER'|'CAMERA'>,[number,number,number]>={FRONT:[0,0,1],BACK:[0,0,-1],RIGHT:[1,0,0],LEFT:[-1,0,0],TOP:[0,1,1e-7],BOTTOM:[0,-1,1e-7]};
export class ViewportNavigation {
camera:PerspectiveCamera|OrthographicCamera=new PerspectiveCamera(45,1,.001,10000);
controls:OrbitControls;
@@ -15,6 +15,15 @@ export class ViewportNavigation {
private selected=new Set<string>();
private hover=false;
private view:ViewportView='USER';
private cameraView=false;
private cameraLock=false;
private sceneCamera:BlenderSceneCameraFrame|null=null;
private sceneCameraKey='';
private lastView:{orbit:{yaw:number;pitch:number;distance:number;target:[number,number,number]};projection:'PERSPECTIVE'|'ORTHOGRAPHIC';fov:number;orthoHeight:number;zoom:number;name:ViewportView}|null=null;
private lastViewName:ViewportView='USER';
private cameraFraming={dx:0,dy:0,camzoom:0,baseZoom:1};
private cameraWriteTimer:number|null=null;
private cameraWriteHandler:((frame:BlenderViewportCamera & {objectId:string})=>void)|null=null;
private changing=false;
private disposed=false;
private fov=45;
@@ -30,6 +39,7 @@ export class ViewportNavigation {
canvas.addEventListener('pointerenter',this.enter);canvas.addEventListener('pointerleave',this.leave);
window.addEventListener('keydown',this.keyDown);window.addEventListener('pointerup',this.pointerEnd);window.addEventListener('pointercancel',this.pointerEnd);window.addEventListener('blur',this.pointerEnd);
canvas.addEventListener('viewport-command',this.commandEvent);
canvas.addEventListener('wheel',this.cameraWheel,{passive:false});
canvas.addEventListener('viewport-ndof',this.ndofEvent);
canvas.addEventListener('viewport-restore',this.restoreEvent);
canvas.addEventListener('viewport-capture',this.captureEvent);
@@ -66,6 +76,85 @@ export class ViewportNavigation {
this.controls=this.createControls(new Vector3(value.target[0],value.target[2],-value.target[1]));
this.changing=false;this.resize();
};
setSceneCamera(frame:BlenderSceneCameraFrame|null):void{
const key=frame?JSON.stringify(frame):'';if(key===this.sceneCameraKey)return;this.sceneCameraKey=key;this.sceneCamera=frame;
if(frame&&this.cameraView&&!this.gesture&&!this.cameraLock)this.applyCameraFrame(frame);
}
setCameraWriteHandler(handler:((frame:BlenderViewportCamera & {objectId:string})=>void)|null):void{this.cameraWriteHandler=handler;}
isCameraView():boolean{return this.cameraView;}
isCameraLocked():boolean{return this.cameraLock;}
getCameraFraming():{dx:number;dy:number;camzoom:number}{return {dx:this.cameraFraming.dx,dy:this.cameraFraming.dy,camzoom:this.cameraFraming.camzoom};}
private cameraPose():BlenderViewportCamera & {objectId:string}{
const position=fromDisplay(this.camera.position),target=fromDisplay(this.controls.target);
const orientation=new Quaternion().setFromRotationMatrix(basis).invert().multiply(this.camera.quaternion);
return {objectId:this.sceneCamera?.objectId??"",position,target,orientation:[orientation.x,orientation.y,orientation.z,orientation.w],projection:this.camera instanceof OrthographicCamera?'ORTHOGRAPHIC':'PERSPECTIVE',fov:this.fov,near:this.camera.near,far:this.camera.far,zoom:this.camera.zoom,orthoHeight:this.orthoHeight};
}
private applyCameraFrame(frame:BlenderSceneCameraFrame):void{
this.changing=true;this.navigationVersion++;
this.controls.removeEventListener('change',this.onChange);this.controls.dispose();
this.fov=frame.fov;this.orthoHeight=frame.orthoHeight;
this.camera=frame.projection==='ORTHOGRAPHIC'?new OrthographicCamera(-1,1,1,-1,frame.near,frame.far):new PerspectiveCamera(frame.fov,1,frame.near,frame.far);
this.camera.position.set(frame.position[0],frame.position[2],-frame.position[1]);
const orientation=new Quaternion().setFromRotationMatrix(basis).multiply(new Quaternion(frame.orientation[0],frame.orientation[1],frame.orientation[2],frame.orientation[3]));
this.camera.quaternion.copy(orientation);this.camera.up.set(0,1,0).applyQuaternion(orientation);
this.camera.zoom=1;
this.controls=this.createControls(new Vector3(frame.target[0],frame.target[2],-frame.target[1]));
this.cameraFraming={dx:0,dy:0,camzoom:frame.camzoom??0,baseZoom:1};
this.setCameraNavigationControls();
this.changing=false;this.resize();
}
private setCameraNavigationControls():void{
if(!this.cameraView){this.controls.enableRotate=true;this.controls.enablePan=true;this.controls.enableZoom=true;return;}
this.controls.enableRotate=this.cameraLock;this.controls.enablePan=this.cameraLock;this.controls.enableZoom=this.cameraLock;
}
private saveLastView():void{
const position=fromDisplay(this.camera.position),target=fromDisplay(this.controls.target);
this.lastView={orbit:orbitStateFromPosition(position,target),projection:this.camera instanceof OrthographicCamera?'ORTHOGRAPHIC':'PERSPECTIVE',fov:this.fov,orthoHeight:this.orthoHeight,zoom:this.camera.zoom,name:this.view==='CAMERA'?'USER':this.view};
}
private restoreLastView():void{
const last=this.lastView;this.lastView=null;
if(!last)return;
this.changing=true;this.navigationVersion++;
this.controls.removeEventListener('change',this.onChange);this.controls.dispose();
this.fov=last.fov;this.orthoHeight=last.orthoHeight;
this.camera=last.projection==='ORTHOGRAPHIC'?new OrthographicCamera(-1,1,1,-1,this.camera.near,this.camera.far):new PerspectiveCamera(last.fov,1,this.camera.near,this.camera.far);
const [x,y,z]=orbitPosition(last.orbit);this.camera.position.set(x,z,-y);this.camera.zoom=last.zoom;
this.controls=this.createControls(new Vector3(last.orbit.target[0],last.orbit.target[2],-last.orbit.target[1]));
this.controls.target.copy(new Vector3(last.orbit.target[0],last.orbit.target[2],-last.orbit.target[1]));
this.view=last.name;this.cameraView=false;this.setCameraNavigationControls();
this.changing=false;this.resize();
}
toggleCameraView():void{
if(this.cameraView){this.restoreLastView();this.canvas.dataset.cameraLocked='false';this.publish();return;}
if(!this.sceneCamera)return;
this.saveLastView();this.cameraView=true;this.view='CAMERA';this.applyCameraFrame(this.sceneCamera);
this.canvas.dataset.cameraLocked=String(this.cameraLock);this.publish();
}
setCameraLock(locked:boolean):void{
this.cameraLock=locked;this.setCameraNavigationControls();
this.canvas.dataset.cameraLocked=String(this.cameraLock);
this.publish();
}
cameraToView():void{
if(this.cameraView||!this.sceneCamera)return;
this.cameraWriteHandler?.(this.cameraPose());
this.toggleCameraView();
}
private scheduleCameraWrite():void{
if(!this.cameraView||!this.cameraLock||!this.cameraWriteHandler)return;
if(this.cameraWriteTimer!==null)window.clearTimeout(this.cameraWriteTimer);
this.cameraWriteTimer=window.setTimeout(()=>{this.cameraWriteTimer=null;if(this.cameraView&&this.cameraLock)this.cameraWriteHandler?.(this.cameraPose());},180);
}
private cameraWheel=(event:WheelEvent)=>{
if(!this.cameraView)return;
if(this.cameraLock)return;
event.preventDefault();
const delta=Math.exp(event.deltaY*0.001);
this.camera.zoom=Math.max(.05,Math.min(20,this.camera.zoom/delta));
this.camera.updateProjectionMatrix();
this.cameraFraming.camzoom=Math.max(-30,Math.min(600,this.cameraFraming.camzoom-event.deltaY*0.02));
this.controls.update();this.publish();
};
private enter=()=>{this.hover=true;};
private leave=()=>{this.hover=false;};
private pointerEnd=(event?:Event)=>{this.gesture=false;if(event?.type==='blur'){this.ndofPending=null;this.navigationVersion++;}};
@@ -77,11 +166,17 @@ export class ViewportNavigation {
};
private keyDown=(event:KeyboardEvent)=>{
const target=event.target as HTMLElement|null;
if((target?.closest('input,textarea,select,[contenteditable=true],[role=dialog]'))||event.altKey||(!this.hover&&document.activeElement!==this.canvas))return;
const cameraChord=event.code==='Numpad0';if((target?.closest('input,textarea,select,[contenteditable=true],[role=dialog]'))||(event.altKey&&!cameraChord)||(!this.hover&&document.activeElement!==this.canvas))return;
let command:ViewportNavigationCommand|undefined;
if(event.code==='Numpad1')command=event.ctrlKey?'BACK':'FRONT';
if(event.code==='Numpad3')command=event.ctrlKey?'LEFT':'RIGHT';
if(event.code==='Numpad7')command=event.ctrlKey?'BOTTOM':'TOP';
if(event.code==='Numpad0'){
event.preventDefault();event.stopImmediatePropagation();
if(event.ctrlKey&&event.altKey)this.command('CAMERA_TO_VIEW');
else if(!event.ctrlKey&&!event.altKey)this.command('CAMERA');
return;
}
if(event.code==='Numpad5')command='PROJECTION';
if(event.code==='NumpadDecimal')command='FRAME_SELECTED';
if(event.code==='Home')command='FRAME_ALL';
@@ -93,7 +188,7 @@ export class ViewportNavigation {
offset.applyQuaternion(new Quaternion().setFromAxisAngle(axis,(event.code==='Numpad4'||event.code==='Numpad8'?1:-1)*Math.PI/12));this.camera.position.copy(this.controls.target).add(offset);this.controls.update();this.publish();
}
};
private commandEvent=(event:Event)=>{const command=(event as CustomEvent<ViewportNavigationCommand>).detail;if(['USER','FRONT','BACK','RIGHT','LEFT','TOP','BOTTOM','PROJECTION','FRAME_ALL','FRAME_SELECTED','ZOOM_IN','ZOOM_OUT'].includes(command))this.command(command);};
private commandEvent=(event:Event)=>{const command=(event as CustomEvent<ViewportNavigationCommand>).detail;if(['USER','CAMERA','FRONT','BACK','RIGHT','LEFT','TOP','BOTTOM','PROJECTION','FRAME_ALL','FRAME_SELECTED','ZOOM_IN','ZOOM_OUT','TOGGLE_CAMERA_VIEW','CAMERA_TO_VIEW','CAMERA_LOCK'].includes(command))this.command(command);};
private ndofEvent=(event:Event)=>{
const detail=(event as CustomEvent<NDOFViewportEvent>).detail;
this.ndofPending={...detail,dt:Math.min(.1,(this.ndofPending?.dt??0)+detail.dt)};
@@ -132,6 +227,9 @@ export class ViewportNavigation {
this.camera.quaternion.copy(previous.quaternion);this.camera.up.copy(previous.up);this.controls=this.createControls(target);this.changing=false;this.resize();
}
command(command:ViewportNavigationCommand):void{
if(command==='CAMERA'||command==='TOGGLE_CAMERA_VIEW'){this.toggleCameraView();return;}
if(command==='CAMERA_TO_VIEW'){this.cameraToView();return;}
if(command==='CAMERA_LOCK'){this.setCameraLock(!this.cameraLock);return;}
if(command==='PROJECTION')this.setProjection(!(this.camera instanceof OrthographicCamera));
else if(command==='ZOOM_IN'||command==='ZOOM_OUT'){
const factor=command==='ZOOM_IN'?1/1.2:1.2;
@@ -175,7 +273,8 @@ export class ViewportNavigation {
publish():void{
if(this.disposed||!this.controls)return;this.navigationVersion++;this.camera.updateMatrixWorld(true);
const position=fromDisplay(this.camera.position),target=fromDisplay(this.controls.target),orbit=orbitStateFromPosition(position,target);
Object.assign(this.canvas.dataset,{cameraPosition:position.map(v=>Number(v.toFixed(6))).join(','),cameraTarget:target.map(v=>Number(v.toFixed(6))).join(','),cameraYaw:orbit.yaw.toFixed(6),cameraPitch:orbit.pitch.toFixed(6),cameraDistance:orbit.distance.toFixed(6),cameraProjection:this.camera instanceof OrthographicCamera?'ORTHOGRAPHIC':'PERSPECTIVE',cameraZoom:this.camera.zoom.toFixed(6),cameraView:this.view});
Object.assign(this.canvas.dataset,{cameraPosition:position.map(v=>Number(v.toFixed(6))).join(','),cameraTarget:target.map(v=>Number(v.toFixed(6))).join(','),cameraYaw:orbit.yaw.toFixed(6),cameraPitch:orbit.pitch.toFixed(6),cameraDistance:orbit.distance.toFixed(6),cameraProjection:this.camera instanceof OrthographicCamera?'ORTHOGRAPHIC':'PERSPECTIVE',cameraZoom:this.camera.zoom.toFixed(6),cameraView:this.cameraView?'CAMERA':this.view,cameraLocked:String(this.cameraLock),cameraFraming:JSON.stringify(this.cameraFraming)});
if(this.cameraView&&this.cameraLock&&!this.changing)this.scheduleCameraWrite();
const inverse=this.camera.quaternion.clone().invert(),axes=[[1,0,0],[0,0,-1],[0,1,0]].map(v=>new Vector3(...v).applyQuaternion(inverse).toArray());
this.canvas.dataset.cameraAxes=JSON.stringify(axes);
this.canvas.dispatchEvent(new CustomEvent('viewport-navigation',{detail:{view:this.view,projection:this.canvas.dataset.cameraProjection,axes}}));
@@ -184,6 +283,6 @@ export class ViewportNavigation {
dispose():void{
this.canvas.removeEventListener('viewport-restore',this.restoreEvent);this.canvas.removeEventListener('viewport-capture',this.captureEvent);
this.canvas.removeEventListener('viewport-ndof',this.ndofEvent);this.ndofPending=null;
this.disposed=true;this.controls.dispose();this.canvas.removeEventListener('pointerdown',this.pointerDown,true);this.canvas.removeEventListener('pointerenter',this.enter);this.canvas.removeEventListener('pointerleave',this.leave);this.canvas.removeEventListener('viewport-command',this.commandEvent);window.removeEventListener('keydown',this.keyDown);window.removeEventListener('pointerup',this.pointerEnd);window.removeEventListener('pointercancel',this.pointerEnd);window.removeEventListener('blur',this.pointerEnd);
this.disposed=true;this.controls.dispose();this.canvas.removeEventListener('pointerdown',this.pointerDown,true);this.canvas.removeEventListener('pointerenter',this.enter);this.canvas.removeEventListener('pointerleave',this.leave);this.canvas.removeEventListener('viewport-command',this.commandEvent);this.canvas.removeEventListener('wheel',this.cameraWheel);if(this.cameraWriteTimer!==null)window.clearTimeout(this.cameraWriteTimer);window.removeEventListener('keydown',this.keyDown);window.removeEventListener('pointerup',this.pointerEnd);window.removeEventListener('pointercancel',this.pointerEnd);window.removeEventListener('blur',this.pointerEnd);
}
}

View File

@@ -92,6 +92,13 @@ export class ViewportRenderer {
private readonly navigation:ViewportNavigation;
get camera(){return this.navigation.camera;}
get controls(){return this.navigation.controls;}
setSceneCamera(frame:Parameters<ViewportNavigation['setSceneCamera']>[0]){this.navigation.setSceneCamera(frame);}
setCameraWriteHandler(handler:Parameters<ViewportNavigation['setCameraWriteHandler']>[0]){this.navigation.setCameraWriteHandler(handler);}
toggleCameraView(){this.navigation.toggleCameraView();}
setCameraLock(locked:boolean){this.navigation.setCameraLock(locked);}
isCameraView(){return this.navigation.isCameraView();}
isCameraLocked(){return this.navigation.isCameraLocked();}
getCameraFraming(){return this.navigation.getCameraFraming();}
private readonly canvas: HTMLCanvasElement;
private readonly importedRoot = new Group();
private readonly importedLights = new Group();

Binary file not shown.