feat: 增加轨迹

This commit is contained in:
2026-09-17 09:09:06 +08:00
parent b9fd289caf
commit e74cf6d28b
42 changed files with 4010 additions and 570 deletions

View File

@@ -10,6 +10,8 @@ import type {
DirectorCameraShot,
DirectorObject,
DirectorProject,
DirectorTrajectoryPoint,
DirectorTrajectoryPointRef,
DirectorTransform,
GeometryPrimitiveType,
MotionKind,
@@ -77,11 +79,17 @@ export interface DirectorUiState {
selectedObjectId: string | null;
selectedObjectIds: string[];
selectedCrowdId: string | null;
/** 当前选中的轨迹点(决定右侧显示「轨迹点」卡片) */
selectedTrajectoryPoint: DirectorTrajectoryPointRef | null;
/** 轨迹打点模式的目标角色 id;非空时画布不可选取其它对象,双击地面即落一个轨迹点 */
trajectoryPlacementObjectId: string | null;
directorInspectorMode: "auto" | "scene";
transformMode: TransformMode;
viewportAspectRatio: ViewportAspectRatio;
viewportRuleOfThirdsEnabled: boolean;
viewportPanelsCollapsed: boolean;
/** 悬浮时间轴(录制面板)是否显示,由视口快捷工具条的「录制」按钮切换 */
timelineVisible: boolean;
}
export interface DirectorState extends DirectorUiState {
@@ -109,10 +117,25 @@ export interface DirectorActions {
setViewportRuleOfThirdsEnabled: (enabled: boolean) => void;
toggleViewportPanelsCollapsed: () => void;
setViewportPanelsCollapsed: (collapsed: boolean) => void;
toggleTimelineVisible: () => void;
setTimelineVisible: (visible: boolean) => void;
selectObject: (id: string | null) => void;
selectCrowd: (crowdId: string | null) => void;
toggleObjectSelection: (id: string) => void;
openSceneInspector: () => void;
// 角色运动轨迹(平面轨迹点)
/** 进入轨迹打点模式:只接受角色,进入后画布不可选取其它对象,双击地面即落一个点 */
startTrajectoryPlacement: (objectId: string) => void;
stopTrajectoryPlacement: () => void;
/** 在场景坐标系里落一个轨迹点(平面:y 固定 0),返回新点 id;目标不是角色时返回 null */
addTrajectoryPoint: (objectId: string, position: [number, number, number]) => string | null;
updateTrajectoryPoint: (
objectId: string,
pointId: string,
patch: Partial<Pick<DirectorTrajectoryPoint, "position" | "rotation">>
) => void;
removeTrajectoryPoint: (objectId: string, pointId: string) => void;
selectTrajectoryPoint: (objectId: string, pointId: string) => void;
updateScene: (patch: Partial<SceneSettings>) => void;
removePanoramaAsset: () => void;
removeImportedAsset: (assetId: string) => void;
@@ -132,6 +155,10 @@ export interface DirectorActions {
addGeometryPrimitive: (geometryType: GeometryPrimitiveType) => void;
addCameraShot: (snapshot?: CameraShotSnapshot) => string;
deleteSelectedObject: () => void;
/** 按 id 删除单个对象(场景树每行的删除按钮) */
deleteObject: (id: string) => void;
/** 按 crowdId 删除整组群众(场景树群众行的删除按钮) */
deleteCrowd: (crowdId: string) => void;
toggleObjectVisible: (id: string) => void;
toggleObjectLocked: (id: string) => void;
applyPosePreset: (id: string, presetId: PosePresetId) => void;
@@ -222,11 +249,14 @@ const DEFAULT_UI_STATE: DirectorUiState = {
selectedObjectId: null,
selectedObjectIds: [],
selectedCrowdId: null,
selectedTrajectoryPoint: null,
trajectoryPlacementObjectId: null,
directorInspectorMode: "auto",
transformMode: "translate",
viewportAspectRatio: "auto",
viewportRuleOfThirdsEnabled: false,
viewportPanelsCollapsed: false,
timelineVisible: true,
};
function normalizeDirectorScenePersistenceScopeId(scopeId: string | null | undefined) {
@@ -276,6 +306,11 @@ function formatSceneItemName(prefix: "角色" | "机位", index: number) {
return `${prefix}${String(index).padStart(2, "0")}`;
}
function createTrajectoryPointId(): string {
return `tp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
/** 画布上单击得到的是精确浮点世界坐标,直接整段存进轨迹点,不取整也不收敛 —— 鼠标点到哪里就是哪里(含小数坐标) */
function getNextSequentialId(existingIds: string[], prefix: string, minimumIndex = 1) {
let maxIndex = minimumIndex - 1;
@@ -347,6 +382,16 @@ function persistLocalModelAsset(asset: DirectorAssetRef) {
writePersistedLocalModelAssets([...persistedAssets, asset]);
}
function readPersistedTrajectoryPointRef(value: unknown): DirectorTrajectoryPointRef | null {
if (!value || typeof value !== "object") return null;
const ref = value as Partial<DirectorTrajectoryPointRef>;
return typeof ref.objectId === "string" && typeof ref.pointId === "string"
? { objectId: ref.objectId, pointId: ref.pointId }
: null;
}
function removePersistedLocalModelAsset(assetId: string) {
writePersistedLocalModelAssets(readPersistedLocalModelAssets().filter((asset) => asset.id !== assetId));
}
@@ -415,17 +460,40 @@ function migrateDirectorProject(project: DirectorProject): DirectorProject {
};
}
/**
* blob: URL 只对创建它的那次页面会话有效,刷新后浏览器已回收。持久化读回来时若全景图还是
* 这种失效地址,说明是上一版本(或上一次会话)留下的,加载必然失败 —— 直接剔除并清空
* panoramaAssetId,回落到纯色天空,而不是弹「全景图加载失败」。
* 新导入的全景图已统一存成 data URL(见 panoramaImport.ts),不会再走到这里。
*/
function dropExpiredPanoramaAsset(project: DirectorProject): DirectorProject {
const panoramaAssetId = project.panoramaAssetId;
if (!panoramaAssetId) return project;
const panoramaAsset = project.assets.find((asset) => asset.id === panoramaAssetId);
if (!panoramaAsset || !panoramaAsset.url.startsWith("blob:")) return project;
return {
...project,
assets: project.assets.filter((asset) => asset.id !== panoramaAssetId),
panoramaAssetId: null,
};
}
function extractPersistedDirectorState(state: DirectorRuntimeState): DirectorState {
return cloneJsonValue({
viewMode: state.viewMode,
selectedObjectId: state.selectedObjectId,
selectedObjectIds: state.selectedObjectIds,
selectedCrowdId: state.selectedCrowdId,
selectedTrajectoryPoint: state.selectedTrajectoryPoint,
trajectoryPlacementObjectId: state.trajectoryPlacementObjectId,
directorInspectorMode: state.directorInspectorMode,
transformMode: state.transformMode,
viewportAspectRatio: state.viewportAspectRatio,
viewportRuleOfThirdsEnabled: state.viewportRuleOfThirdsEnabled,
viewportPanelsCollapsed: state.viewportPanelsCollapsed,
timelineVisible: state.timelineVisible,
project: state.project,
});
}
@@ -444,7 +512,9 @@ function writePersistedDirectorState(state: DirectorState) {
function createStateFromPersistedProject(project: DirectorProject, options: DirectorStateOptions = {}): DirectorState {
return {
...DEFAULT_UI_STATE,
project: withPersistedLocalAssets(migrateDirectorProject(cloneJsonValue(project)), options.includePersistedLocalAssets),
project: dropExpiredPanoramaAsset(
withPersistedLocalAssets(migrateDirectorProject(cloneJsonValue(project)), options.includePersistedLocalAssets)
),
};
}
@@ -474,12 +544,16 @@ function readPersistedDirectorState(options: DirectorStateOptions = {}): Directo
? state.selectedObjectIds.filter((item): item is string => typeof item === "string")
: [],
selectedCrowdId: typeof state.selectedCrowdId === "string" ? state.selectedCrowdId : null,
selectedTrajectoryPoint: readPersistedTrajectoryPointRef(state.selectedTrajectoryPoint),
trajectoryPlacementObjectId:
typeof state.trajectoryPlacementObjectId === "string" ? state.trajectoryPlacementObjectId : null,
directorInspectorMode: state.directorInspectorMode === "scene" ? "scene" : "auto",
transformMode:
state.transformMode === "rotate" || state.transformMode === "scale" ? state.transformMode : "translate",
viewportAspectRatio: state.viewportAspectRatio ?? "auto",
viewportRuleOfThirdsEnabled: Boolean(state.viewportRuleOfThirdsEnabled),
viewportPanelsCollapsed: Boolean(state.viewportPanelsCollapsed),
timelineVisible: state.timelineVisible === false ? false : true,
project: withPersistedLocalAssets(
migrateDirectorProject(cloneJsonValue(state.project)),
options.includePersistedLocalAssets
@@ -938,6 +1012,107 @@ function getOrderedSelectedObjectIds(state: DirectorState) {
return state.selectedObjectId ? [state.selectedObjectId] : [];
}
/**
* 按 id 删除一组对象,并做级联清理:
* - 摄像机对象连带删掉它背后的机位(cameras)
* - 原本注视被删对象的机位退回「手动坐标」注视
* - 只被这些对象引用的模型素材一并回收(本地导入的素材保留)
* - 时间轴上对应的物体轨道 / 相机轨道一起删掉
* 没被删掉的选中项继续保持选中(删别人不会丢掉当前选中)。
*/
function deleteObjectsByIds(state: DirectorRuntimeState, objectIds: string[]): DirectorRuntimeState {
if (!objectIds.length) return state;
const objectIdSet = new Set(objectIds);
const targetObjects = state.project.objects.filter((item) => objectIdSet.has(item.id));
if (!targetObjects.length) {
return {
...state,
selectedObjectId: null,
selectedObjectIds: [],
};
}
const linkedCameraIds = new Set(
targetObjects.filter((item) => item.kind === "camera" && item.linkedCameraId).map((item) => item.linkedCameraId)
);
const nextCameras = linkedCameraIds.size
? state.project.cameras.filter((camera) => !linkedCameraIds.has(camera.id))
: state.project.cameras;
const nextFocusedCameras = nextCameras.map((camera) =>
camera.targetObjectId && objectIdSet.has(camera.targetObjectId)
? {
...camera,
targetMode: "manual" as const,
targetObjectId: null,
}
: camera
);
const nextActiveCameraId =
state.project.activeCameraId && linkedCameraIds.has(state.project.activeCameraId)
? nextFocusedCameras[0]?.id ?? null
: state.project.activeCameraId;
const nextObjects = state.project.objects.filter((item) => !objectIdSet.has(item.id));
const assetsById = new Map(state.project.assets.map((item) => [item.id, item]));
const remainingAssetRefIds = new Set(
nextObjects.map((item) => item.assetRefId).filter((assetRefId): assetRefId is string => Boolean(assetRefId))
);
const removedAssetRefIds = new Set(
targetObjects
.map((item) => item.assetRefId)
.filter((assetRefId): assetRefId is string => {
if (typeof assetRefId !== "string" || remainingAssetRefIds.has(assetRefId)) return false;
return assetsById.get(assetRefId)?.assetSource !== "local";
})
);
// 同步清理被删对象的关键帧轨道:物体轨道按 objectId、相机轨道按 linkedCameraId
const nextTracks = state.project.timeline.tracks.filter((track) => !objectIdSet.has(track.objectId));
const nextCameraTracks = state.project.timeline.cameraTracks.filter(
(track) => !linkedCameraIds.has(track.cameraId)
);
const timelineUnchanged =
nextTracks.length === state.project.timeline.tracks.length &&
nextCameraTracks.length === state.project.timeline.cameraTracks.length;
const remainingSelectedObjectIds = getOrderedSelectedObjectIds(state).filter((id) => !objectIdSet.has(id));
const remainingSelectedCrowdId =
state.selectedCrowdId && nextObjects.some((item) => item.crowdId === state.selectedCrowdId)
? state.selectedCrowdId
: null;
return {
...state,
selectedObjectId: remainingSelectedObjectIds[remainingSelectedObjectIds.length - 1] ?? null,
selectedObjectIds: remainingSelectedObjectIds,
selectedCrowdId: remainingSelectedCrowdId,
// 所属角色被删了,它的轨迹点选中态 / 打点模式也要一并清掉
selectedTrajectoryPoint:
state.selectedTrajectoryPoint && objectIdSet.has(state.selectedTrajectoryPoint.objectId)
? null
: state.selectedTrajectoryPoint,
trajectoryPlacementObjectId:
state.trajectoryPlacementObjectId && objectIdSet.has(state.trajectoryPlacementObjectId)
? null
: state.trajectoryPlacementObjectId,
directorInspectorMode: "auto",
project: {
...state.project,
assets: state.project.assets.filter((item) => !removedAssetRefIds.has(item.id)),
objects: nextObjects,
cameras: nextFocusedCameras,
activeCameraId: nextActiveCameraId,
timeline: timelineUnchanged
? state.project.timeline
: {
...state.project.timeline,
tracks: nextTracks,
cameraTracks: nextCameraTracks,
},
},
};
}
function createObjectIdForDuplicate(existingObjects: DirectorObject[], source: DirectorObject) {
if (source.kind === "camera") {
return getNextSequentialId(
@@ -1259,6 +1434,16 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
...state,
viewportPanelsCollapsed: collapsed,
})),
toggleTimelineVisible: () =>
commitUiMutation((state) => ({
...state,
timelineVisible: !state.timelineVisible,
})),
setTimelineVisible: (visible) =>
commitUiMutation((state) => ({
...state,
timelineVisible: visible,
})),
setViewMode: (mode) =>
commitUiMutation((state) => ({
...state,
@@ -1280,6 +1465,8 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
selectedObjectId: id,
selectedObjectIds: id ? [id] : [],
selectedCrowdId: null,
// 改选其它对象就退出「轨迹点」卡片
selectedTrajectoryPoint: null,
directorInspectorMode: "auto",
project: {
...state.project,
@@ -1309,6 +1496,7 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
selectedCrowdId: crowdId,
selectedObjectId: crowdMemberIds[crowdMemberIds.length - 1] ?? null,
selectedObjectIds: crowdMemberIds,
selectedTrajectoryPoint: null,
directorInspectorMode: "auto",
};
}),
@@ -1328,6 +1516,7 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
selectedObjectId: nextSelectedObjectId,
selectedObjectIds: nextSelectedObjectIds,
selectedCrowdId: null,
selectedTrajectoryPoint: null,
directorInspectorMode: "auto",
project: {
...state.project,
@@ -1345,7 +1534,108 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
selectedObjectId: null,
selectedObjectIds: [],
selectedCrowdId: null,
selectedTrajectoryPoint: null,
})),
startTrajectoryPlacement: (objectId) =>
commitUiMutation((state) => {
const target = state.project.objects.find((item) => item.id === objectId);
if (!target || target.kind !== "character") return state;
return {
...state,
selectedObjectId: objectId,
selectedObjectIds: [objectId],
selectedCrowdId: null,
selectedTrajectoryPoint: null,
directorInspectorMode: "auto",
trajectoryPlacementObjectId: objectId,
};
}),
stopTrajectoryPlacement: () =>
commitUiMutation((state) => ({ ...state, trajectoryPlacementObjectId: null })),
addTrajectoryPoint: (objectId, position) => {
// 只有角色能挂运动轨迹,先在外面判定,目标不合法时直接返回 null
const target = get().project.objects.find((item) => item.id === objectId);
if (!target || target.kind !== "character") return null;
const pointId = createTrajectoryPointId();
commitMutation((state) => {
const target = state.project.objects.find((item) => item.id === objectId);
if (!target || target.kind !== "character") return state;
const nextPoint: DirectorTrajectoryPoint = {
id: pointId,
// 只取 x / z:新建的轨迹点永远落在地面(y = 0),所以轨迹默认是平面的。
// 坐标不取整:鼠标在地面点到的精确浮点位置就是落点(用户要求非整数坐标也能放)。
position: [position[0], 0, position[2]],
rotation: [0, 0, 0],
};
return {
...state,
selectedTrajectoryPoint: { objectId, pointId },
project: {
...state.project,
objects: updateObjectById(state.project.objects, objectId, (item) => ({
...item,
trajectoryPoints: [...(item.trajectoryPoints ?? []), nextPoint],
})),
},
};
});
return pointId;
},
updateTrajectoryPoint: (objectId, pointId, patch) =>
commitMutation((state) => ({
...state,
project: {
...state.project,
objects: updateObjectById(state.project.objects, objectId, (item) => ({
...item,
trajectoryPoints: (item.trajectoryPoints ?? []).map((point) =>
point.id === pointId
? {
...point,
// 直接采用传入的精确值(含小数),不再收敛
position: patch.position ? patch.position : point.position,
rotation: patch.rotation ? patch.rotation : point.rotation,
}
: point
),
})),
},
})),
removeTrajectoryPoint: (objectId, pointId) =>
commitMutation((state) => ({
...state,
selectedTrajectoryPoint:
state.selectedTrajectoryPoint?.objectId === objectId && state.selectedTrajectoryPoint.pointId === pointId
? null
: state.selectedTrajectoryPoint,
project: {
...state.project,
objects: updateObjectById(state.project.objects, objectId, (item) => ({
...item,
trajectoryPoints: (item.trajectoryPoints ?? []).filter((point) => point.id !== pointId),
})),
},
})),
selectTrajectoryPoint: (objectId, pointId) =>
commitUiMutation((state) => {
const target = state.project.objects.find((item) => item.id === objectId);
if (!target?.trajectoryPoints?.some((point) => point.id === pointId)) return state;
return {
...state,
selectedObjectId: objectId,
selectedObjectIds: [objectId],
selectedCrowdId: null,
directorInspectorMode: "auto",
selectedTrajectoryPoint: { objectId, pointId },
};
}),
updateScene: (patch) =>
commitMutation((state) => ({
...state,
@@ -1846,90 +2136,12 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
return nextCameraId;
},
deleteSelectedObject: () =>
commitMutation((state) => {
const selectedObjectIds = getOrderedSelectedObjectIds(state);
if (!selectedObjectIds.length) return state;
const selectedObjects = state.project.objects.filter((item) => selectedObjectIds.includes(item.id));
if (!selectedObjects.length) {
return {
...state,
selectedObjectId: null,
selectedObjectIds: [],
};
}
const linkedCameraIds = new Set(
selectedObjects
.filter((item) => item.kind === "camera" && item.linkedCameraId)
.map((item) => item.linkedCameraId)
);
const nextCameras = linkedCameraIds.size
? state.project.cameras.filter((camera) => !linkedCameraIds.has(camera.id))
: state.project.cameras;
const selectedObjectIdSet = new Set(selectedObjectIds);
const nextFocusedCameras = nextCameras.map((camera) =>
camera.targetObjectId && selectedObjectIdSet.has(camera.targetObjectId)
? {
...camera,
targetMode: "manual" as const,
targetObjectId: null,
}
: camera
);
const nextActiveCameraId =
state.project.activeCameraId && linkedCameraIds.has(state.project.activeCameraId)
? nextFocusedCameras[0]?.id ?? null
: state.project.activeCameraId;
const nextObjects = state.project.objects.filter((item) => !selectedObjectIds.includes(item.id));
const assetsById = new Map(state.project.assets.map((item) => [item.id, item]));
const remainingAssetRefIds = new Set(
nextObjects.map((item) => item.assetRefId).filter((assetRefId): assetRefId is string => Boolean(assetRefId))
);
const removedAssetRefIds = new Set(
selectedObjects
.map((item) => item.assetRefId)
.filter(
(assetRefId): assetRefId is string => {
if (typeof assetRefId !== "string" || remainingAssetRefIds.has(assetRefId)) return false;
return assetsById.get(assetRefId)?.assetSource !== "local";
}
)
);
// 同步清理被删对象的关键帧轨道:物体轨道按 objectId、相机轨道按 linkedCameraId
const nextTracks = state.project.timeline.tracks.filter(
(track) => !selectedObjectIdSet.has(track.objectId)
);
const nextCameraTracks = state.project.timeline.cameraTracks.filter(
(track) => !linkedCameraIds.has(track.cameraId)
);
const timelineUnchanged =
nextTracks.length === state.project.timeline.tracks.length &&
nextCameraTracks.length === state.project.timeline.cameraTracks.length;
return {
...state,
selectedObjectId: null,
selectedObjectIds: [],
selectedCrowdId: null,
directorInspectorMode: "auto",
project: {
...state.project,
assets: state.project.assets.filter((item) => !removedAssetRefIds.has(item.id)),
objects: nextObjects,
cameras: nextFocusedCameras,
activeCameraId: nextActiveCameraId,
timeline: timelineUnchanged
? state.project.timeline
: {
...state.project.timeline,
tracks: nextTracks,
cameraTracks: nextCameraTracks,
},
},
};
}),
commitMutation((state) => deleteObjectsByIds(state, getOrderedSelectedObjectIds(state))),
/** 删除单个对象(角色 / 几何体 / 模型 / 摄像机对象),供场景树每行的删除按钮使用 */
deleteObject: (id) => commitMutation((state) => deleteObjectsByIds(state, [id])),
/** 删除整个群众组 */
deleteCrowd: (crowdId) =>
commitMutation((state) => deleteObjectsByIds(state, getCrowdMemberIds(state.project.objects, crowdId))),
toggleObjectVisible: (id) =>
commitMutation((state) => ({
...state,