import { Html, Line, TransformControls, type TransformControlsProps } from "@react-three/drei";
import { useLoader, type ThreeEvent } from "@react-three/fiber";
import { Suspense, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import {
Box3,
CatmullRomCurve3,
DoubleSide,
Matrix4,
Quaternion,
Vector3,
type Group,
type Object3D,
} from "three";
import type { TransformControls as TransformControlsImpl } from "three-stdlib";
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
import type {
DirectorAssetRef,
DirectorCameraShot,
DirectorObject,
GeometryPrimitiveType,
} from "../schema/directorProject";
import {
degreesRotationToRadians,
radiansRotationToDegrees,
} from "../schema/directorProject";
import {
VIEWPORT_CAMERA_ASPECT,
VIEWPORT_CAMERA_FRUSTUM_DEPTH,
VIEWPORT_CAMERA_VISUAL_SCALE,
} from "../schema/cameraGeometry";
import { VIEWPORT_OBJECT_LABEL_VERTICAL_GAP } from "../schema/viewportLabels";
import { evaluateTrajectoryMotionAtTime, getTrajectoryPointTimes } from "../schema/trajectoryMotion";
import type { TransformMode } from "../store/directorStore";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { CharacterModel } from "../runtime/CharacterModel";
import { getGroundedLabelY } from "../runtime/mannequin/bodyTypes";
import { getUE4GroundedLabelY } from "../runtime/ue4Mannequin/ue4MannequinRig";
import { getEffectiveGroundOpacity } from "./panoramaMath";
import { getGroundAxisLines } from "./sceneAxes";
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE } from "./sceneGround";
import { getSceneGroupTransform } from "./sceneTransform";
import { HIDE_FROM_VIEWPORT_CAPTURE_KEY } from "./viewportCapture";
import { getCrowdAnchorTransform } from "../store/directorStore";
export { getEffectiveGroundOpacity, getPanoramaRotationRadians } from "./panoramaMath";
const VIEWPORT_CAMERA_LINE = "#A9D8FF";
/** 轨迹:路点标记 / 连线的兜底色(默认沿用角色自身颜色),选中点用高亮色 */
const TRAJECTORY_FALLBACK_COLOR = "#0396FF";
const TRAJECTORY_SELECTED_COLOR = "#FFC857";
/** 轨迹打点拾取平面的尺寸:比地面大得多,保证「双击画布任意位置」都能落点 */
const TRAJECTORY_PLANE_SIZE = 600;
const VIEWPORT_CAMERA_LINE_OPACITY = 0.92;
const VIEWPORT_CAMERA_HIT_PADDING = 0.06;
const VIEWPORT_CAMERA_FORWARD = new Vector3(0, 0, 1);
const VIEWPORT_CAMERA_WORLD_UP = new Vector3(0, 1, 0);
const VIEWPORT_CAMERA_BODY_CENTER: CameraWirePoint = [0, 0, -0.52 * VIEWPORT_CAMERA_VISUAL_SCALE];
const VIEWPORT_CAMERA_BODY_SIZE: CameraWirePoint = [
0.4 * VIEWPORT_CAMERA_VISUAL_SCALE,
0.4 * VIEWPORT_CAMERA_VISUAL_SCALE,
1 * VIEWPORT_CAMERA_VISUAL_SCALE,
];
const VIEWPORT_CAMERA_BODY_FRONT_Z = VIEWPORT_CAMERA_BODY_CENTER[2] + VIEWPORT_CAMERA_BODY_SIZE[2] / 2;
const VIEWPORT_CAMERA_LENS_TIP: CameraWirePoint = [0, 0, 0.2 * VIEWPORT_CAMERA_VISUAL_SCALE];
const ROLE_LABEL_DISTANCE_FACTOR = 3;
const IMPORTED_MODEL_TARGET_MAX_SIZE = 2;
type CameraWirePoint = [number, number, number];
type CameraWirePointLine = CameraWirePoint[];
type CameraWirePart = "body" | "lens" | "reel";
type CameraWireLine = {
part: CameraWirePart;
points: CameraWirePointLine;
};
type CameraHitArea = {
args: CameraWirePoint;
position: CameraWirePoint;
};
function ViewportObjectLabel({
children,
position,
}: {
children: ReactNode;
position: [number, number, number];
}) {
return (
{children}
);
}
function ViewportTransformControls({
mode,
object,
onObjectChange,
translationSnap,
editingId,
}: {
mode: TransformMode;
object: TransformControlsProps["object"];
onObjectChange: TransformControlsProps["onObjectChange"];
translationSnap?: number | null;
editingId?: string;
}) {
const controlsRef = useRef(null);
const setControlsRef = useCallback((controls: TransformControlsImpl | null) => {
controlsRef.current = controls;
if (controls) {
controls.userData[HIDE_FROM_VIEWPORT_CAPTURE_KEY] = true;
}
}, []);
const beginUndoBatch = useDirectorStore((state) => state.beginUndoBatch);
const endUndoBatch = useDirectorStore((state) => state.endUndoBatch);
const setEditingObjectId = usePlaybackStore((state) => state.setEditingObjectId);
return (
{
beginUndoBatch();
// 拖拽期间标记该对象为“编辑中”,PlaybackTransformSync 会跳过它,避免覆盖拖拽值
setEditingObjectId(editingId ?? null);
}}
onMouseUp={endUndoBatch}
onObjectChange={onObjectChange}
translationSnap={translationSnap ?? undefined}
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
/>
);
}
export function getViewportCameraQuaternion(
position: [number, number, number],
target: [number, number, number]
) {
const origin = new Vector3(...position);
const direction = new Vector3(...target).sub(origin);
if (direction.lengthSq() === 0) return new Quaternion();
const forward = direction.normalize();
const up =
Math.abs(forward.dot(VIEWPORT_CAMERA_WORLD_UP)) > 0.999
? new Vector3(0, 0, 1)
: VIEWPORT_CAMERA_WORLD_UP;
const matrix = new Matrix4().lookAt(origin, origin.clone().sub(forward), up);
return new Quaternion().setFromRotationMatrix(matrix);
}
export function getViewportCameraOpaqueDepthRange() {
const zValues = getViewportCameraBodyWireframeLines()
.filter((line) => line.part !== "lens")
.flatMap((line) => line.points)
.map((point) => point[2]);
return {
minZ: Math.min(...zValues),
maxZ: Math.max(...zValues),
};
}
export function getViewportCameraLabelY() {
const points = getViewportCameraBodyWireframeLines().flatMap((line) => line.points);
const modelTopY = Math.max(...points.map((point) => point[1]));
return modelTopY + VIEWPORT_OBJECT_LABEL_VERTICAL_GAP;
}
export function getImportedModelNormalization(bounds: Box3, targetMaxSize = IMPORTED_MODEL_TARGET_MAX_SIZE) {
if (bounds.isEmpty()) {
return {
position: [0, 0, 0] as [number, number, number],
scale: 1,
};
}
const size = new Vector3();
const center = new Vector3();
bounds.getSize(size);
bounds.getCenter(center);
const maxSize = Math.max(size.x, size.y, size.z);
const scale = Number.isFinite(maxSize) && maxSize > 0 ? targetMaxSize / maxSize : 1;
return {
position: [-center.x * scale, -bounds.min.y * scale, -center.z * scale] as [number, number, number],
scale,
};
}
function createBoxWireframeLines({
center,
size,
}: {
center: CameraWirePoint;
size: CameraWirePoint;
}): CameraWirePointLine[] {
const [cx, cy, cz] = center;
const [width, height, depth] = size;
const x0 = cx - width / 2;
const x1 = cx + width / 2;
const y0 = cy - height / 2;
const y1 = cy + height / 2;
const z0 = cz - depth / 2;
const z1 = cz + depth / 2;
const corners: Record = {
bbl: [x0, y0, z0],
bbr: [x1, y0, z0],
btl: [x0, y1, z0],
btr: [x1, y1, z0],
fbl: [x0, y0, z1],
fbr: [x1, y0, z1],
ftl: [x0, y1, z1],
ftr: [x1, y1, z1],
};
return [
[corners.bbl, corners.bbr],
[corners.bbr, corners.btr],
[corners.btr, corners.btl],
[corners.btl, corners.bbl],
[corners.fbl, corners.fbr],
[corners.fbr, corners.ftr],
[corners.ftr, corners.ftl],
[corners.ftl, corners.fbl],
[corners.bbl, corners.fbl],
[corners.bbr, corners.fbr],
[corners.btr, corners.ftr],
[corners.btl, corners.ftl],
];
}
function createCircleWireframeLine({
center,
radius,
segments = 32,
plane = "xy",
}: {
center: CameraWirePoint;
radius: number;
segments?: number;
plane?: "xy" | "xz" | "yz";
}): CameraWirePointLine {
const [cx, cy, cz] = center;
return Array.from({ length: segments + 1 }, (_, index) => {
const angle = (Math.PI * 2 * index) / segments;
const a = Math.cos(angle) * radius;
const b = Math.sin(angle) * radius;
if (plane === "xz") return [cx + a, cy, cz + b];
if (plane === "yz") return [cx, cy + a, cz + b];
return [cx + a, cy + b, cz];
});
}
function createInvertedTetrahedronLensWireframeLines(): CameraWirePointLine[] {
const backTopLeft: CameraWirePoint = [
-0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_BODY_FRONT_Z,
];
const backTopRight: CameraWirePoint = [
0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_BODY_FRONT_Z,
];
const backBottomRight: CameraWirePoint = [
0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
-0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_BODY_FRONT_Z,
];
const backBottomLeft: CameraWirePoint = [
-0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
-0.10 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_BODY_FRONT_Z,
];
const frontTopLeft: CameraWirePoint = [
-0.25 * VIEWPORT_CAMERA_VISUAL_SCALE,
0.2 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_LENS_TIP[2],
];
const frontTopRight: CameraWirePoint = [
0.25 * VIEWPORT_CAMERA_VISUAL_SCALE,
0.2 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_LENS_TIP[2],
];
const frontBottomRight: CameraWirePoint = [
0.25 * VIEWPORT_CAMERA_VISUAL_SCALE,
-0.2 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_LENS_TIP[2],
];
const frontBottomLeft: CameraWirePoint = [
-0.25 * VIEWPORT_CAMERA_VISUAL_SCALE,
-0.2 * VIEWPORT_CAMERA_VISUAL_SCALE,
VIEWPORT_CAMERA_LENS_TIP[2],
];
return [
[backTopLeft, backTopRight, backBottomRight, backBottomLeft, backTopLeft],
[frontTopLeft, frontTopRight, frontBottomRight, frontBottomLeft, frontTopLeft],
[backTopLeft, frontTopLeft],
[backTopRight, frontTopRight],
[backBottomRight, frontBottomRight],
[backBottomLeft, frontBottomLeft],
];
}
function withCameraPart(part: CameraWirePart, lines: CameraWirePointLine[]): CameraWireLine[] {
return lines.map((points) => ({ part, points }));
}
export function getViewportCameraBodyWireframeLines(): CameraWireLine[] {
return [
...withCameraPart("body", [
...createBoxWireframeLines({ center: VIEWPORT_CAMERA_BODY_CENTER, size: VIEWPORT_CAMERA_BODY_SIZE }),
]),
...withCameraPart("lens", createInvertedTetrahedronLensWireframeLines()),
...withCameraPart("reel", [
createCircleWireframeLine({
center: [0, 0.44 * VIEWPORT_CAMERA_VISUAL_SCALE, -0.78 * VIEWPORT_CAMERA_VISUAL_SCALE],
radius: 0.21 * VIEWPORT_CAMERA_VISUAL_SCALE,
plane: "yz",
}),
createCircleWireframeLine({
center: [0, 0.44 * VIEWPORT_CAMERA_VISUAL_SCALE, -0.34 * VIEWPORT_CAMERA_VISUAL_SCALE],
radius: 0.21 * VIEWPORT_CAMERA_VISUAL_SCALE,
plane: "yz",
}),
]),
];
}
export function getViewportCameraHitArea(): CameraHitArea {
const points = getViewportCameraBodyWireframeLines().flatMap((line) => line.points);
const minX = Math.min(...points.map((point) => point[0]));
const maxX = Math.max(...points.map((point) => point[0]));
const minY = Math.min(...points.map((point) => point[1]));
const maxY = Math.max(...points.map((point) => point[1]));
const minZ = Math.min(...points.map((point) => point[2]));
const maxZ = Math.max(...points.map((point) => point[2]));
return {
args: [
maxX - minX + VIEWPORT_CAMERA_HIT_PADDING * 2,
maxY - minY + VIEWPORT_CAMERA_HIT_PADDING * 2,
maxZ - minZ + VIEWPORT_CAMERA_HIT_PADDING * 2,
],
position: [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2],
};
}
function NormalizedImportedObject({ object }: { object: Object3D }) {
const { clone, normalization } = useMemo(() => {
const clonedObject = object.clone(true);
clonedObject.updateMatrixWorld(true);
return {
clone: clonedObject,
normalization: getImportedModelNormalization(new Box3().setFromObject(clonedObject)),
};
}, [object]);
return (
);
}
function FbxModel({ url }: { url: string }) {
const object = useLoader(FBXLoader, url);
return ;
}
function ObjModel({ url }: { url: string }) {
const object = useLoader(OBJLoader, url);
return ;
}
function ImportedModel({
fileName,
url,
}: {
fileName: string;
url: string;
}) {
if (/\.fbx$/i.test(fileName)) return ;
if (/\.obj$/i.test(fileName)) return ;
return null;
}
function GeometryPrimitiveModel({
color = "#d7e7ff",
geometryType,
}: {
color?: string;
geometryType: GeometryPrimitiveType;
}) {
const material = ;
if (geometryType === "sphere") {
return (
{material}
);
}
if (geometryType === "cylinder") {
return (
{material}
);
}
if (geometryType === "torus") {
return (
{material}
);
}
if (geometryType === "cone") {
return (
{material}
);
}
if (geometryType === "pyramid") {
return (
{material}
);
}
return (
{material}
);
}
function ObjectSceneNode({
asset,
item,
selected,
showLabels,
transformMode,
transformable,
translationSnap,
onSelect,
}: {
asset?: DirectorAssetRef;
item: DirectorObject;
selected: boolean;
showLabels: boolean;
transformMode: TransformMode;
transformable: boolean;
translationSnap: number | null;
onSelect?: (item: DirectorObject) => void;
}) {
const groupRef = useRef(null!);
const [measuredCharacterLabel, setMeasuredCharacterLabel] = useState<{
key: string;
y: number;
} | null>(null);
const updateObjectTransform = useDirectorStore((state) => state.updateObjectTransform);
const isPlaying = usePlaybackStore((state) => state.isPlaying);
const motionTime = usePlaybackStore((state) => state.playhead);
const playbackDuration = usePlaybackStore((state) => state.duration);
// 当前选中的轨迹点若属于本角色,则抑制角色自身的变换 gizmo(拖拽交给轨迹点 gizmo)。
const selectedTrajectoryPoint = useDirectorStore((state) => state.selectedTrajectoryPoint);
const activeTrajectoryPoint =
!isPlaying && selectedTrajectoryPoint?.objectId === item.id
? item.trajectoryPoints?.find((point) => point.id === selectedTrajectoryPoint.pointId)
: undefined;
// 带轨迹的角色:位置 / 朝向由「人物轨迹 + 播放头」决定。
// 这里把同一个结果也算进 props,和 PlaybackTransformSync 每帧的写入保持一致,
// 否则 React 每次重渲染都会把角色拉回起始姿态,和轨迹走位互相打架(表现为抖动/闪回)。
const trajectoryMotion = useMemo(
() => evaluateTrajectoryMotionAtTime(item, playbackDuration, motionTime),
[item, playbackDuration, motionTime]
);
const isImportedModel = asset?.sourceType === "model";
const characterLabelKey = `${item.id}:${item.bodyType ?? ""}:${item.characterRig?.rigType ?? ""}`;
const fallbackCharacterLabelY =
item.kind === "character"
? item.characterRig?.rigType === "ue4-mannequin"
? getUE4GroundedLabelY(item.bodyType)
: getGroundedLabelY(item.bodyType)
: 1.25;
const characterLabelY =
measuredCharacterLabel?.key === characterLabelKey ? measuredCharacterLabel.y : fallbackCharacterLabelY;
const handleCharacterLabelAnchorYChange = useCallback(
(anchorY: number) => {
setMeasuredCharacterLabel((current) => {
const nextY = Number(anchorY.toFixed(4));
if (current?.key === characterLabelKey && Math.abs(current.y - nextY) < 0.0001) {
return current;
}
return {
key: characterLabelKey,
y: nextY,
};
});
},
[characterLabelKey]
);
function commitTransformFromViewport() {
const group = groupRef.current;
if (!group) return;
// group.rotation 是弧度,存入数据模型前要转成度数(数据模型里旋转一律用度数)
updateObjectTransform(item.id, {
position: [group.position.x, group.position.y, group.position.z],
rotation: radiansRotationToDegrees([group.rotation.x, group.rotation.y, group.rotation.z]),
scale: [group.scale.x, group.scale.y, group.scale.z],
});
}
const node = (
{
event.stopPropagation();
onSelect?.(item);
}}
>
{isImportedModel && asset ? (
) : item.kind === "character" ? (
<>
{showLabels ? (
{item.name}
) : null}
>
) : item.kind === "prop" && item.geometryType ? (
) : null}
);
// 选中了本角色的某个轨迹点时,角色被预览到该路点上,此时不再显示角色自身的变换 gizmo,
// 避免和轨迹点的拖拽 gizmo 重叠;拖拽由轨迹点 gizmo 负责。
const showGizmo = selected && transformable && !isPlaying && !activeTrajectoryPoint;
if (!showGizmo) return node;
return (
<>
{node}
>
);
}
function CrowdTransformRig({
crowdId,
objects,
selected,
transformMode,
transformable,
translationSnap,
}: {
crowdId: string;
objects: DirectorObject[];
selected: boolean;
transformMode: TransformMode;
transformable: boolean;
translationSnap: number | null;
}) {
const groupRef = useRef(null!);
const updateCrowdTransform = useDirectorStore((state) => state.updateCrowdTransform);
const crowdAnchor = useMemo(() => getCrowdAnchorTransform(objects, crowdId), [objects, crowdId]);
function commitCrowdTransformFromViewport() {
const group = groupRef.current;
if (!group) return;
// group.rotation 是弧度,存入数据模型前要转成度数
updateCrowdTransform(crowdId, {
position: [group.position.x, group.position.y, group.position.z],
rotation: radiansRotationToDegrees([group.rotation.x, group.rotation.y, group.rotation.z]),
scale: [group.scale.x, group.scale.y, group.scale.z],
});
}
if (!selected || !transformable || !crowdAnchor) return null;
return (
<>
>
);
}
export function getViewportCameraFrustumLines(
camera: DirectorCameraShot
): Array<[[number, number, number], [number, number, number]]> {
const frameDepth = VIEWPORT_CAMERA_FRUSTUM_DEPTH;
const halfWidth = frameDepth * Math.tan((camera.fov * Math.PI) / 360);
const halfHeight = halfWidth / VIEWPORT_CAMERA_ASPECT;
const topLeft: [number, number, number] = [-halfWidth, halfHeight, frameDepth];
const topRight: [number, number, number] = [halfWidth, halfHeight, frameDepth];
const bottomRight: [number, number, number] = [halfWidth, -halfHeight, frameDepth];
const bottomLeft: [number, number, number] = [-halfWidth, -halfHeight, frameDepth];
return [
[VIEWPORT_CAMERA_LENS_TIP, topLeft],
[VIEWPORT_CAMERA_LENS_TIP, topRight],
[VIEWPORT_CAMERA_LENS_TIP, bottomRight],
[VIEWPORT_CAMERA_LENS_TIP, bottomLeft],
[topLeft, topRight],
[topRight, bottomRight],
[bottomRight, bottomLeft],
[bottomLeft, topLeft],
];
}
function ViewportCameraRig({
camera,
object,
selected,
showLabel,
transformMode,
transformable,
translationSnap,
}: {
camera: DirectorCameraShot;
object?: DirectorObject;
selected: boolean;
showLabel: boolean;
transformMode: TransformMode;
transformable: boolean;
translationSnap: number | null;
}) {
const groupRef = useRef(null!);
const selectObject = useDirectorStore((state) => state.selectObject);
const updateCamera = useDirectorStore((state) => state.updateCamera);
const trajectoryPlacementObjectId = useDirectorStore((state) => state.trajectoryPlacementObjectId);
const bodyWireframeLines = useMemo(() => getViewportCameraBodyWireframeLines(), []);
const cameraHitArea = useMemo(() => getViewportCameraHitArea(), []);
const cameraLabelY = useMemo(() => getViewportCameraLabelY(), []);
const frustumLines = useMemo(() => getViewportCameraFrustumLines(camera), [camera]);
const cameraQuaternion = useMemo(
() => getViewportCameraQuaternion(camera.transform.position, camera.target),
[camera.target, camera.transform.position]
);
useLayoutEffect(() => {
groupRef.current?.quaternion?.copy?.(cameraQuaternion);
}, [cameraQuaternion]);
function commitCameraTransformFromViewport() {
const group = groupRef.current;
if (!group) return;
const position: [number, number, number] = [group.position.x, group.position.y, group.position.z];
const forward = VIEWPORT_CAMERA_FORWARD.clone().applyQuaternion(group.quaternion).normalize();
const currentDistance = new Vector3(...camera.target).distanceTo(group.position);
const nextTarget = group.position.clone().add(forward.multiplyScalar(Math.max(currentDistance, 0.1)));
updateCamera(camera.id, {
transform: {
position,
rotation: [group.rotation.x, group.rotation.y, group.rotation.z],
scale: [group.scale.x, group.scale.y, group.scale.z],
},
target: [nextTarget.x, nextTarget.y, nextTarget.z],
});
}
function selectCameraFromViewport(event: ThreeEvent) {
// 打点模式下画布不响应选取(机位也一样),双击地面才落轨迹点
if (trajectoryPlacementObjectId) {
event.stopPropagation();
return;
}
event.stopPropagation();
selectObject(object?.id ?? null);
}
const node = (
{showLabel ? (
{camera.name}
) : null}
{bodyWireframeLines.map((line, index) => (
))}
{frustumLines.map((points, index) => (
))}
);
if (!selected || !transformable) return node;
return (
<>
{node}
>
);
}
/**
* 轨迹打点的拾取平面:不可见但可被射线命中,只在「打点模式」挂载。
*
* 落点坐标不依赖「被打中的是哪块面」:直接用场景组自身的 worldToLocal 把世界交点换算成
* 场景组局部坐标,再取其中的 x / z(场景组局部里 y 是高度,平面轨迹固定 y=0)。
* 这样无论射线命中旋转的拾取平面,还是同处地面的网格,横纵轴都正确,
* 不会出现 Z 被算成 0、所有点塌到 X 轴上的问题。
*/
/** 从「场景组局部坐标」里取地面落点 [x, 0, z](y 由轨迹语义固定为 0)。 */
export function getTrajectoryGroundPoint(local: Vector3): [number, number, number] {
return [local.x, 0, local.z];
}
function TrajectoryPlacementPlane({
objectId,
sceneGroupRef,
}: {
objectId: string;
sceneGroupRef: RefObject;
}) {
const addTrajectoryPoint = useDirectorStore((state) => state.addTrajectoryPoint);
return (
{
event.stopPropagation();
// 用「场景组」自身的 worldToLocal 把世界交点换算成场景组局部坐标,
// 不依赖被打中的是哪块面(拾取平面 / 地面都绕 X 轴转 -90° 躺平)。
const group = sceneGroupRef.current;
if (!group) return;
const local = group.worldToLocal(event.point.clone());
addTrajectoryPoint(objectId, getTrajectoryGroundPoint(local));
}}
>
);
}
/**
* 一个角色的运动轨迹:每个路点画成贴地实心小球(可点选),相邻路点之间用穿过各点的
* 平滑曲线(Catmull-Rom 样条,视觉上即「贝塞尔曲线」式的圆顺路径)连起来。
* 和网格、坐标轴一样属于编辑器辅助元素,不参与截图 / 视频导出。
*/
function TrajectoryPath({
owner,
selectedPointId,
interactive,
}: {
owner: DirectorObject;
selectedPointId: string | null;
interactive: boolean;
}) {
const selectTrajectoryPoint = useDirectorStore((state) => state.selectTrajectoryPoint);
const updateTrajectoryPoint = useDirectorStore((state) => state.updateTrajectoryPoint);
const isPlaying = usePlaybackStore((state) => state.isPlaying);
const setPlaying = usePlaybackStore((state) => state.setPlaying);
const setPlayhead = usePlaybackStore((state) => state.setPlayhead);
const playbackDuration = usePlaybackStore((state) => state.duration);
const translationSnap = useDirectorStore((state) => (state.project.scene.snapToGrid ? 1 : null));
const selectedGroupRef = useRef(null!);
const points = owner.trajectoryPoints ?? [];
const color = owner.color ?? TRAJECTORY_FALLBACK_COLOR;
// 只有在「可交互(非打点模式)且非播放中」且确实有选中点时,才给选中点挂拖拽 gizmo
const draggable = interactive && !isPlaying && Boolean(selectedPointId);
/** 平滑曲线采样点:从「角色自身位置」出发,依次穿过每个路点(Catmull-Rom 样条,
* centripetal 型避免路径自交打环)。这样轨迹是从角色身上延伸出来的,而不是凭空
* 从第一个路点开始。 */
const sampledCurve = useMemo(() => {
if (points.length < 1) return null;
// 起点:角色位置(与路点一样抬到网格高度,保证贴地、不出现竖直跳变)
const ownerPoint = new Vector3(
owner.transform.position[0],
owner.transform.position[1] + GROUND_GRID_ELEVATION,
owner.transform.position[2]
);
const curvePoints = [
ownerPoint,
...points.map(
(point) => new Vector3(point.position[0], point.position[1] + GROUND_GRID_ELEVATION, point.position[2])
),
];
const curve = new CatmullRomCurve3(curvePoints, false, "centripetal");
const segments = Math.max(24, (curvePoints.length - 1) * 24);
return curve.getPoints(segments).map((point) => [point.x, point.y, point.z] as [number, number, number]);
}, [points, owner.transform.position]);
if (!points.length) return null;
function selectPoint(pointId: string, event: ThreeEvent) {
event.stopPropagation();
selectTrajectoryPoint(owner.id, pointId);
// 轨迹点本身就是时间轴上的关键帧:选中即把指针定位到该点时间,
// 角色按「沿轨迹行走」的插值正好预览到这一点的位置与朝向(0s = 角色起始位置)。
const pointIndex = points.findIndex((point) => point.id === pointId);
if (pointIndex < 0) return;
setPlaying(false);
setPlayhead(getTrajectoryPointTimes(points, playbackDuration)[pointIndex] ?? 0);
}
/**
* 拖拽过程中实时把 gizmo 移动后的坐标写回选中的轨迹点:
* 路点组在场景组局部坐标里抬高了 GROUND_GRID_ELEVATION(小球贴地用),
* 这里把抬升减回去,存回的是轨迹语义坐标(y 默认 0 = 贴地)。
* 写回后「角色预览」会跟着同一份坐标实时移动,所见即所得。
*/
function commitSelectedPoint() {
const group = selectedGroupRef.current;
if (!group || !selectedPointId) return;
updateTrajectoryPoint(owner.id, selectedPointId, {
position: [group.position.x, group.position.y - GROUND_GRID_ELEVATION, group.position.z],
});
}
return (
{sampledCurve ? (
) : null}
{points.map((point) => {
const selected = point.id === selectedPointId;
return (
{/* 透明大圆盘只为了让小球更好点中(俯视时点地面圆盘比点小球容易) */}
selectPoint(point.id, event) : undefined}
>
{/* 实心小球:选中用高亮色,否则用角色色。
这里必须写深度(用材质默认值 true,别改成 false):地面是 transparent 材质,
即使透明度为 1 也走透明通道、排在所有不透明物体之后渲染;
小球一旦不写深度,地面就会整片盖住它 —— 表现就是「地面不透明时看不到轨迹点」。 */}
selectPoint(point.id, event) : undefined}
>
);
})}
{/* 选中点挂拖拽 gizmo:拖到哪,角色预览就跟到哪。打点模式 / 播放中 / 无选中点时不挂。 */}
{draggable ? (
) : null}
);
}
/**
* 地面坐标轴:X / Z 两条穿过原点贯通整块地面,颜色与坐标控件(全景球)对应轴一致。
* 抬到和网格同一高度,避免与地面平面 z-fighting;和网格一样不参与截图/视频导出。
*/
function GroundAxes({ y }: { y: number }) {
return (
<>
{getGroundAxisLines(y).map((axis) => (
))}
>
);
}
export function SceneRoot() {
const scene = useDirectorStore((state) => state.project.scene);
const assets = useDirectorStore((state) => state.project.assets);
const objects = useDirectorStore((state) => state.project.objects);
const cameras = useDirectorStore((state) => state.project.cameras);
const panoramaAssetId = useDirectorStore((state) => state.project.panoramaAssetId);
const viewMode = useDirectorStore((state) => state.viewMode);
const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
const selectedCrowdId = useDirectorStore((state) => state.selectedCrowdId);
const transformMode = useDirectorStore((state) => state.transformMode);
const selectObject = useDirectorStore((state) => state.selectObject);
const selectCrowd = useDirectorStore((state) => state.selectCrowd);
const trajectoryPlacementObjectId = useDirectorStore((state) => state.trajectoryPlacementObjectId);
const selectedTrajectoryPoint = useDirectorStore((state) => state.selectedTrajectoryPoint);
const panoramaAsset = assets.find((item) => item.id === panoramaAssetId);
const translationSnap = scene.snapToGrid ? 1 : null;
const assetsById = useMemo(() => new Map(assets.map((item) => [item.id, item])), [assets]);
const cameraObjectsByCameraId = useMemo(() => {
return new Map(
objects
.filter((item) => item.kind === "camera" && item.linkedCameraId)
.map((item) => [item.linkedCameraId as string, item])
);
}, [objects]);
const crowdLocksById = useMemo(() => {
const result = new Map();
const crowdMembers = objects.filter((item) => item.kind === "character" && item.crowdId);
crowdMembers.forEach((item) => {
const crowdId = item.crowdId as string;
result.set(crowdId, (result.get(crowdId) ?? false) || item.locked);
});
return result;
}, [objects]);
// 场景组根节点:轨迹打点时用它自带的 worldToLocal 把世界交点换算成场景组局部坐标,
// 这样落点不依赖被射线打中的是哪块面,Z(纵轴)永远正确。
const sceneGroupRef = useRef(null);
function handleObjectSelect(item: DirectorObject) {
// 打点模式下画布不响应选取:这一下双击只用来给目标角色落一个轨迹点
if (trajectoryPlacementObjectId) return;
if (item.kind === "character" && item.crowdId) {
selectCrowd(item.crowdId);
return;
}
selectObject(item.id);
}
return (
{scene.showGround ? (
<>
>
) : null}
{/* 打点模式:整块地面变成落点用的拾取面(不可见) */}
{trajectoryPlacementObjectId ? (
) : null}
{objects
.filter((item) => (item.trajectoryPoints ?? []).length > 0)
.map((item) => (
))}
{objects
.filter((item) => item.visible && item.kind !== "camera")
.map((item) => {
const asset = item.assetRefId ? assetsById.get(item.assetRefId) : undefined;
return (
);
})}
{Array.from(new Set(objects.map((item) => item.crowdId).filter((item): item is string => typeof item === "string"))).map(
(crowdId) => (
)
)}
{viewMode === "director"
? cameras
.map((camera) => ({ camera, object: cameraObjectsByCameraId.get(camera.id) }))
.filter(({ object }) => object?.visible ?? true)
.map(({ camera, object }) => (
))
: null}
);
}