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} ); }