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:
187
web/protocol/area-layout.ts
Normal file
187
web/protocol/area-layout.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { AreaIR, EditorType, WorkspaceIR } from "./ui-schema";
|
||||
|
||||
export type AreaDirection = "LEFT" | "RIGHT" | "UP" | "DOWN";
|
||||
export type AreaSplitDirection = "HORIZONTAL" | "VERTICAL";
|
||||
|
||||
export type AreaOperation =
|
||||
| { type: "SET_AREA_EDITOR"; areaId: string; editor: EditorType }
|
||||
| { type: "SPLIT_AREA"; areaId: string; direction: AreaSplitDirection; factor: number }
|
||||
| { type: "JOIN_AREA"; areaId: string; targetAreaId: string }
|
||||
| { type: "SWAP_AREAS"; areaId: string; targetAreaId: string }
|
||||
| { type: "DUPLICATE_AREA"; areaId: string }
|
||||
| { type: "CLOSE_AREA"; areaId: string }
|
||||
| { type: "TOGGLE_MAXIMIZE_AREA"; areaId: string }
|
||||
| { type: "TOGGLE_FULLSCREEN_AREA"; areaId: string }
|
||||
| { type: "SET_AREA_LOCK"; areaId: string; cameraLock: boolean }
|
||||
| { type: "SET_AREA_RECT"; areaId: string; rect: AreaIR["rect"] }
|
||||
| { type: "RESET_LAYOUT" };
|
||||
|
||||
export const AREA_MIN_WIDTH = 0.03;
|
||||
export const AREA_MIN_HEIGHT = 0.03;
|
||||
|
||||
export interface MeasuredArea {
|
||||
id: string;
|
||||
editor: EditorType;
|
||||
contentKey: string | null;
|
||||
rect: AreaIR["rect"];
|
||||
}
|
||||
|
||||
function clampRect(rect: AreaIR["rect"]): AreaIR["rect"] {
|
||||
const width = Math.max(AREA_MIN_WIDTH, Math.min(1, rect.width));
|
||||
const height = Math.max(AREA_MIN_HEIGHT, Math.min(1, rect.height));
|
||||
return {
|
||||
x: Math.max(0, Math.min(1 - width, rect.x)),
|
||||
y: Math.max(0, Math.min(1 - height, rect.y)),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
function newAreaId(areas: AreaIR[]): string {
|
||||
let index = 1;
|
||||
while (areas.some((area) => area.id === `area-${index}`)) index += 1;
|
||||
return `area-${index}`;
|
||||
}
|
||||
|
||||
export function splitArea(area: AreaIR, direction: AreaSplitDirection, factor: number): { active: AreaIR; added: AreaIR } | null {
|
||||
const clamped = Math.max(0.1, Math.min(0.9, Number.isFinite(factor) ? factor : 0.5));
|
||||
const rect = area.rect;
|
||||
if (direction === "VERTICAL") {
|
||||
const width = rect.width * clamped;
|
||||
if (width < AREA_MIN_WIDTH || rect.width - width < AREA_MIN_WIDTH) return null;
|
||||
return {
|
||||
active: { ...area, rect: clampRect({ x: rect.x, y: rect.y, width, height: rect.height }) },
|
||||
added: { ...area, id: `${area.id}:split`, contentKey: null, rect: clampRect({ x: rect.x + width, y: rect.y, width: rect.width - width, height: rect.height }) },
|
||||
};
|
||||
}
|
||||
const height = rect.height * clamped;
|
||||
if (height < AREA_MIN_HEIGHT || rect.height - height < AREA_MIN_HEIGHT) return null;
|
||||
return {
|
||||
active: { ...area, rect: clampRect({ x: rect.x, y: rect.y + rect.height - height, width: rect.width, height }) },
|
||||
added: { ...area, id: `${area.id}:split`, contentKey: null, rect: clampRect({ x: rect.x, y: rect.y, width: rect.width, height: rect.height - height }) },
|
||||
};
|
||||
}
|
||||
|
||||
function overlapLength(aStart: number, aSize: number, bStart: number, bSize: number): number {
|
||||
return Math.max(0, Math.min(aStart + aSize, bStart + bSize) - Math.max(aStart, bStart));
|
||||
}
|
||||
|
||||
export function findAreaNeighbor(areas: AreaIR[], areaId: string, direction: AreaDirection): AreaIR | null {
|
||||
const source = areas.find((area) => area.id === areaId);
|
||||
if (!source) return null;
|
||||
const r = source.rect;
|
||||
let best: { area: AreaIR; distance: number } | null = null;
|
||||
for (const candidate of areas) {
|
||||
if (candidate.id === areaId) continue;
|
||||
const c = candidate.rect;
|
||||
const verticalOverlap = overlapLength(r.x, r.width, c.x, c.width);
|
||||
const horizontalOverlap = overlapLength(r.y, r.height, c.y, c.height);
|
||||
let distance = Number.POSITIVE_INFINITY;
|
||||
if (direction === "LEFT" && horizontalOverlap >= AREA_MIN_HEIGHT && c.x + c.width <= r.x + 0.0005) distance = r.x - (c.x + c.width);
|
||||
else if (direction === "RIGHT" && horizontalOverlap >= AREA_MIN_HEIGHT && c.x >= r.x + r.width - 0.0005) distance = c.x - (r.x + r.width);
|
||||
else if (direction === "DOWN" && verticalOverlap >= AREA_MIN_WIDTH && c.y + c.height <= r.y + 0.0005) distance = r.y - (c.y + c.height);
|
||||
else if (direction === "UP" && verticalOverlap >= AREA_MIN_WIDTH && c.y >= r.y + r.height - 0.0005) distance = c.y - (r.y + r.height);
|
||||
if (distance < (best?.distance ?? Number.POSITIVE_INFINITY)) best = { area: candidate, distance };
|
||||
}
|
||||
return best?.area ?? null;
|
||||
}
|
||||
|
||||
export function joinArea(areas: AreaIR[], areaId: string, targetAreaId: string): AreaIR[] {
|
||||
const active = areas.find((area) => area.id === areaId);
|
||||
const target = areas.find((area) => area.id === targetAreaId);
|
||||
if (!active || !target || active.id === target.id) return areas;
|
||||
const x = Math.min(active.rect.x, target.rect.x);
|
||||
const y = Math.min(active.rect.y, target.rect.y);
|
||||
const width = Math.max(active.rect.x + active.rect.width, target.rect.x + target.rect.width) - x;
|
||||
const height = Math.max(active.rect.y + active.rect.height, target.rect.y + target.rect.height) - y;
|
||||
return areas
|
||||
.filter((area) => area.id !== target.id)
|
||||
.map((area) => area.id === active.id ? { ...area, rect: clampRect({ x, y, width, height }) } : area);
|
||||
}
|
||||
|
||||
function closeArea(areas: AreaIR[], areaId: string): AreaIR[] {
|
||||
if (areas.length <= 1) return areas;
|
||||
let best: AreaIR | null = null;
|
||||
let bestScore = 0;
|
||||
for (const direction of ["LEFT", "RIGHT", "UP", "DOWN"] as const) {
|
||||
const neighbor = findAreaNeighbor(areas, areaId, direction);
|
||||
if (!neighbor) continue;
|
||||
const source = areas.find((area) => area.id === areaId)!;
|
||||
const score = direction === "LEFT" || direction === "RIGHT"
|
||||
? overlapLength(source.rect.y, source.rect.height, neighbor.rect.y, neighbor.rect.height)
|
||||
: overlapLength(source.rect.x, source.rect.width, neighbor.rect.x, neighbor.rect.width);
|
||||
if (score > bestScore) { best = neighbor; bestScore = score; }
|
||||
}
|
||||
return best ? joinArea(areas, areaId, best.id) : areas;
|
||||
}
|
||||
|
||||
export function applyAreaOperation(workspace: WorkspaceIR, operation: AreaOperation): WorkspaceIR {
|
||||
const areas = workspace.areas;
|
||||
const activeId = workspace.activeAreaId;
|
||||
const update = (next: AreaIR[], activeAreaId = workspace.activeAreaId): WorkspaceIR => ({
|
||||
...workspace,
|
||||
areas: next,
|
||||
activeAreaId: next.some((area) => area.id === activeAreaId) ? activeAreaId : next[0]?.id ?? activeAreaId,
|
||||
revision: workspace.revision + 1,
|
||||
});
|
||||
switch (operation.type) {
|
||||
case "SET_AREA_EDITOR":
|
||||
return update(areas.map((area) => area.id === operation.areaId ? { ...area, editor: operation.editor } : area));
|
||||
case "SET_AREA_LOCK":
|
||||
return update(areas.map((area) => area.id === operation.areaId ? { ...area, view3d: { cameraLock: operation.cameraLock } } : area));
|
||||
case "SPLIT_AREA": {
|
||||
const active = areas.find((area) => area.id === operation.areaId);
|
||||
if (!active) return workspace;
|
||||
const split = splitArea(active, operation.direction, operation.factor);
|
||||
if (!split) return workspace;
|
||||
const added = { ...split.added, id: newAreaId(areas) };
|
||||
return update(areas.map((area) => area.id === active.id ? split.active : area).concat(added));
|
||||
}
|
||||
case "DUPLICATE_AREA": {
|
||||
const active = areas.find((area) => area.id === operation.areaId);
|
||||
if (!active) return workspace;
|
||||
const split = splitArea(active, "VERTICAL", 0.5);
|
||||
if (!split) return workspace;
|
||||
return update(areas.map((area) => area.id === active.id ? split.active : area).concat({ ...split.added, id: newAreaId(areas) }));
|
||||
}
|
||||
case "JOIN_AREA":
|
||||
return update(joinArea(areas, operation.areaId, operation.targetAreaId));
|
||||
case "CLOSE_AREA":
|
||||
return update(closeArea(areas, operation.areaId), operation.areaId === activeId ? areas.find((area) => area.id !== activeId)?.id ?? activeId : activeId);
|
||||
case "SWAP_AREAS": {
|
||||
const a = areas.find((area) => area.id === operation.areaId);
|
||||
const b = areas.find((area) => area.id === operation.targetAreaId);
|
||||
if (!a || !b || a.id === b.id) return workspace;
|
||||
return update(areas.map((area) => area.id === a.id
|
||||
? { ...area, editor: b.editor, view3d: b.view3d }
|
||||
: area.id === b.id ? { ...area, editor: a.editor, view3d: a.view3d } : area));
|
||||
}
|
||||
case "TOGGLE_MAXIMIZE_AREA":
|
||||
return update(areas.map((area) => ({ ...area, maximized: area.id === operation.areaId ? !area.maximized : false })), operation.areaId);
|
||||
case "TOGGLE_FULLSCREEN_AREA":
|
||||
return update(areas.map((area) => ({ ...area, maximized: area.id === operation.areaId ? !area.maximized : false })), operation.areaId);
|
||||
case "SET_AREA_RECT":
|
||||
return update(areas.map((area) => area.id === operation.areaId ? { ...area, rect: clampRect(operation.rect) } : area));
|
||||
case "RESET_LAYOUT":
|
||||
return { ...workspace, layoutMode: "default", areas: areas.map((area) => ({ ...area, maximized: false })), revision: workspace.revision + 1 };
|
||||
default:
|
||||
return workspace;
|
||||
}
|
||||
}
|
||||
|
||||
export function measureAreaRects(elements: Array<{ id: string; editor: EditorType; contentKey: string | null; rect: DOMRect }>, gridRect: DOMRect): AreaIR[] {
|
||||
if (gridRect.width <= 0 || gridRect.height <= 0) return [];
|
||||
return elements.map((element) => ({
|
||||
id: element.id,
|
||||
editor: element.editor,
|
||||
contentKey: element.contentKey,
|
||||
regions: [],
|
||||
rect: clampRect({
|
||||
x: (element.rect.left - gridRect.left) / gridRect.width,
|
||||
y: 1 - (element.rect.bottom - gridRect.top) / gridRect.height,
|
||||
width: element.rect.width / gridRect.width,
|
||||
height: element.rect.height / gridRect.height,
|
||||
}),
|
||||
maximized: false,
|
||||
}));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {applyAreaOperation} from "./area-layout";
|
||||
export const BLENDER_WORKSPACE_IDS = [
|
||||
"Layout",
|
||||
"Modeling",
|
||||
@@ -45,6 +46,9 @@ export interface AreaIR {
|
||||
regions: RegionIR[];
|
||||
rect: { x: number; y: number; width: number; height: number };
|
||||
maximized: boolean;
|
||||
/** Default workspace slot key used to reuse hardcoded React content. */
|
||||
contentKey?: string | null;
|
||||
view3d?: { cameraLock: boolean };
|
||||
}
|
||||
|
||||
export interface WorkspaceIR {
|
||||
@@ -53,6 +57,8 @@ export interface WorkspaceIR {
|
||||
areas: AreaIR[];
|
||||
activeAreaId: string;
|
||||
revision: number;
|
||||
layoutMode?: "default" | "custom";
|
||||
focusAreaId?: string | null;
|
||||
}
|
||||
|
||||
export interface UIContextIR {
|
||||
@@ -81,7 +87,9 @@ export type UICommand =
|
||||
| { type: "setActiveArea"; areaId: string }
|
||||
| { type: "toggleOperatorSearch"; open?: boolean }
|
||||
| { type: "toggleMenu"; menu?: string }
|
||||
| { type: "toggleSidebar"; visible?: boolean };
|
||||
| { type: "toggleSidebar"; visible?: boolean }
|
||||
| { type: "areaOperation"; workspaceId?: WorkspaceId; operation: import("./area-layout").AreaOperation; measured?: AreaIR[] }
|
||||
| { type: "setWorkspaceLayout"; workspaceId: WorkspaceId; areas: AreaIR[] };
|
||||
|
||||
function regions(): RegionIR[] {
|
||||
return [
|
||||
@@ -225,5 +233,31 @@ export function reduceUICommand(state: WebWorkspaceState, command: UICommand): W
|
||||
return { ...state, openMenu: state.openMenu === command.menu ? null : command.menu ?? null, operatorSearchOpen: null === command.menu ? state.operatorSearchOpen : false };
|
||||
case "toggleSidebar":
|
||||
return { ...state, sidebarVisible: command.visible ?? !state.sidebarVisible };
|
||||
case "areaOperation": {
|
||||
const workspaceId = command.workspaceId ?? state.context.workspaceId;
|
||||
const workspace = state.workspaces[workspaceId];
|
||||
if (!workspace) return state;
|
||||
const base = command.measured && command.measured.length > 0
|
||||
? { ...workspace, areas: command.measured, layoutMode: "custom" as const, revision: workspace.revision + 1 }
|
||||
: workspace;
|
||||
const next = applyAreaOperation({ ...base, layoutMode: "custom" }, command.operation);
|
||||
return {
|
||||
...state,
|
||||
workspaces: { ...state.workspaces, [workspaceId]: next },
|
||||
context: {
|
||||
...state.context,
|
||||
workspaceId,
|
||||
activeAreaId: next.activeAreaId,
|
||||
activeEditor: next.areas.find((area) => area.id === next.activeAreaId)?.editor ?? state.context.activeEditor,
|
||||
revision: state.context.revision + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "setWorkspaceLayout": {
|
||||
const workspace = state.workspaces[command.workspaceId];
|
||||
if (!workspace) return state;
|
||||
const next = { ...workspace, areas: command.areas, layoutMode: "custom" as const, revision: workspace.revision + 1 };
|
||||
return { ...state, workspaces: { ...state.workspaces, [command.workspaceId]: next } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ export interface ViewportCameraState extends ViewportOrbitState {
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
export type ViewportView = 'USER' | 'FRONT' | 'BACK' | 'RIGHT' | 'LEFT' | 'TOP' | 'BOTTOM';
|
||||
export type ViewportNavigationCommand = ViewportView | 'PROJECTION' | 'FRAME_ALL' | 'FRAME_SELECTED' | 'ZOOM_IN' | 'ZOOM_OUT';
|
||||
export type ViewportView = 'USER' | 'CAMERA' | 'FRONT' | 'BACK' | 'RIGHT' | 'LEFT' | 'TOP' | 'BOTTOM';
|
||||
export type ViewportNavigationCommand = ViewportView | 'PROJECTION' | 'FRAME_ALL' | 'FRAME_SELECTED' | 'ZOOM_IN' | 'ZOOM_OUT' | 'TOGGLE_CAMERA_VIEW' | 'CAMERA_TO_VIEW' | 'CAMERA_LOCK';
|
||||
export interface ViewportNavigationCamera {
|
||||
position: [number,number,number];
|
||||
quaternion: [number,number,number,number];
|
||||
@@ -30,6 +30,35 @@ export interface BlenderViewportCamera {
|
||||
orientation: [number,number,number,number];
|
||||
projection: 'PERSPECTIVE'|'ORTHOGRAPHIC';
|
||||
fov:number; near:number; far:number; zoom:number; orthoHeight:number;
|
||||
shiftX?:number; shiftY?:number; objectId?:string; lensMm?:number; sensorWidthMm?:number; sensorHeightMm?:number; camzoom?:number;
|
||||
}
|
||||
|
||||
export interface BlenderSceneCameraFrame extends BlenderViewportCamera {
|
||||
objectId: string;
|
||||
lensMm: number;
|
||||
sensorWidthMm: number;
|
||||
sensorHeightMm: number;
|
||||
sensorFit: number;
|
||||
shift: [number, number];
|
||||
orthoScale: number;
|
||||
camzoom: number;
|
||||
}
|
||||
|
||||
export function cameraZoomFac(camzoom: number): number {
|
||||
const clamped = Math.max(-30, Math.min(600, camzoom));
|
||||
return ((Math.SQRT2 + clamped / 50) ** 2) / 4;
|
||||
}
|
||||
|
||||
export function eulerFromQuaternion(q: readonly [number, number, number, number]): [number, number, number] {
|
||||
const [x, y, z, w] = q;
|
||||
const sinr = 2 * (w * x + y * z);
|
||||
const cosr = 1 - 2 * (x * x + y * y);
|
||||
const roll = Math.atan2(sinr, cosr);
|
||||
const sinp = 2 * (w * y - z * x);
|
||||
const pitch = Math.abs(sinp) >= 1 ? Math.sign(sinp) * Math.PI / 2 : Math.asin(sinp);
|
||||
const siny = 2 * (w * z + x * y);
|
||||
const cosy = 1 - 2 * (y * y + z * z);
|
||||
return [roll, pitch, Math.atan2(siny, cosy)];
|
||||
}
|
||||
|
||||
export const VIEWPORT_DEFAULT_ORBIT: Readonly<ViewportOrbitState> = Object.freeze({
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface WebSettingsFile {
|
||||
preferences: WebPreferencesIR;
|
||||
areaLayouts: Record<string, AreaLayoutRecord>;
|
||||
areaEditorOverrides: Record<string, EditorType>;
|
||||
cameraLocks: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export const DEFAULT_WEB_PREFERENCES: WebPreferencesIR = {
|
||||
|
||||
Reference in New Issue
Block a user