1157 lines
40 KiB
TypeScript
1157 lines
40 KiB
TypeScript
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 (
|
||
<Html
|
||
center
|
||
distanceFactor={ROLE_LABEL_DISTANCE_FACTOR}
|
||
pointerEvents="none"
|
||
position={position}
|
||
sprite
|
||
transform
|
||
zIndexRange={[0, 1]}
|
||
>
|
||
<div className="role-label">{children}</div>
|
||
</Html>
|
||
);
|
||
}
|
||
|
||
function ViewportTransformControls({
|
||
mode,
|
||
object,
|
||
onObjectChange,
|
||
translationSnap,
|
||
editingId,
|
||
}: {
|
||
mode: TransformMode;
|
||
object: TransformControlsProps["object"];
|
||
onObjectChange: TransformControlsProps["onObjectChange"];
|
||
translationSnap?: number | null;
|
||
editingId?: string;
|
||
}) {
|
||
const controlsRef = useRef<TransformControlsImpl | null>(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 (
|
||
<TransformControls
|
||
ref={setControlsRef}
|
||
mode={mode}
|
||
object={object}
|
||
onMouseDown={() => {
|
||
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<string, CameraWirePoint> = {
|
||
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 (
|
||
<group
|
||
position={normalization.position}
|
||
scale={[normalization.scale, normalization.scale, normalization.scale]}
|
||
>
|
||
<primitive object={clone} />
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function FbxModel({ url }: { url: string }) {
|
||
const object = useLoader(FBXLoader, url);
|
||
|
||
return <NormalizedImportedObject object={object} />;
|
||
}
|
||
|
||
function ObjModel({ url }: { url: string }) {
|
||
const object = useLoader(OBJLoader, url);
|
||
|
||
return <NormalizedImportedObject object={object} />;
|
||
}
|
||
|
||
function ImportedModel({
|
||
fileName,
|
||
url,
|
||
}: {
|
||
fileName: string;
|
||
url: string;
|
||
}) {
|
||
if (/\.fbx$/i.test(fileName)) return <FbxModel url={url} />;
|
||
if (/\.obj$/i.test(fileName)) return <ObjModel url={url} />;
|
||
return null;
|
||
}
|
||
|
||
function GeometryPrimitiveModel({
|
||
color = "#d7e7ff",
|
||
geometryType,
|
||
}: {
|
||
color?: string;
|
||
geometryType: GeometryPrimitiveType;
|
||
}) {
|
||
const material = <meshStandardMaterial color={color} metalness={0.02} roughness={0.68} />;
|
||
|
||
if (geometryType === "sphere") {
|
||
return (
|
||
<mesh name="geometry-sphere" position={[0, 0.55, 0]}>
|
||
<sphereGeometry args={[0.55, 32, 16]} />
|
||
{material}
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
if (geometryType === "cylinder") {
|
||
return (
|
||
<mesh name="geometry-cylinder" position={[0, 0.6, 0]}>
|
||
<cylinderGeometry args={[0.45, 0.45, 1.2, 32]} />
|
||
{material}
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
if (geometryType === "torus") {
|
||
return (
|
||
<mesh name="geometry-torus" position={[0, 0.14, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||
<torusGeometry args={[0.45, 0.14, 16, 48]} />
|
||
{material}
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
if (geometryType === "cone") {
|
||
return (
|
||
<mesh name="geometry-cone" position={[0, 0.55, 0]}>
|
||
<coneGeometry args={[0.5, 1.1, 32]} />
|
||
{material}
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
if (geometryType === "pyramid") {
|
||
return (
|
||
<mesh name="geometry-pyramid" position={[0, 0.55, 0]}>
|
||
<coneGeometry args={[0.55, 1.1, 4]} />
|
||
{material}
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<mesh name="geometry-box" position={[0, 0.5, 0]}>
|
||
<boxGeometry args={[1, 1, 1]} />
|
||
{material}
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
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<Group>(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 = (
|
||
<group
|
||
ref={groupRef}
|
||
position={trajectoryMotion ? trajectoryMotion.position : item.transform.position}
|
||
// 数据模型里旋转是度数,应用到 Three.js 的 rotation 属性(弧度)前要转换,
|
||
// 否则 360° 会被当成 360 弧度 ≈ 57 圈。
|
||
rotation={degreesRotationToRadians(trajectoryMotion ? trajectoryMotion.rotation : item.transform.rotation)}
|
||
scale={item.transform.scale}
|
||
userData={{ directorObjectId: item.id }}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onSelect?.(item);
|
||
}}
|
||
>
|
||
{isImportedModel && asset ? (
|
||
<Suspense fallback={null}>
|
||
<ImportedModel fileName={asset.fileName} url={asset.url} />
|
||
</Suspense>
|
||
) : item.kind === "character" ? (
|
||
<>
|
||
<Suspense fallback={null}>
|
||
<CharacterModel
|
||
bodyType={item.bodyType}
|
||
color={item.color}
|
||
onLabelAnchorYChange={handleCharacterLabelAnchorYChange}
|
||
rigState={item.characterRig}
|
||
motionTime={motionTime}
|
||
characterId={item.id}
|
||
/>
|
||
</Suspense>
|
||
{showLabels ? (
|
||
<ViewportObjectLabel position={[0, characterLabelY, 0]}>{item.name}</ViewportObjectLabel>
|
||
) : null}
|
||
</>
|
||
) : item.kind === "prop" && item.geometryType ? (
|
||
<GeometryPrimitiveModel color={item.color} geometryType={item.geometryType} />
|
||
) : null}
|
||
</group>
|
||
);
|
||
|
||
// 选中了本角色的某个轨迹点时,角色被预览到该路点上,此时不再显示角色自身的变换 gizmo,
|
||
// 避免和轨迹点的拖拽 gizmo 重叠;拖拽由轨迹点 gizmo 负责。
|
||
const showGizmo = selected && transformable && !isPlaying && !activeTrajectoryPoint;
|
||
|
||
if (!showGizmo) return node;
|
||
|
||
return (
|
||
<>
|
||
{node}
|
||
<ViewportTransformControls
|
||
mode={transformMode}
|
||
object={groupRef}
|
||
onObjectChange={commitTransformFromViewport}
|
||
translationSnap={transformMode === "translate" ? translationSnap : null}
|
||
editingId={item.id}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function CrowdTransformRig({
|
||
crowdId,
|
||
objects,
|
||
selected,
|
||
transformMode,
|
||
transformable,
|
||
translationSnap,
|
||
}: {
|
||
crowdId: string;
|
||
objects: DirectorObject[];
|
||
selected: boolean;
|
||
transformMode: TransformMode;
|
||
transformable: boolean;
|
||
translationSnap: number | null;
|
||
}) {
|
||
const groupRef = useRef<Group>(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 (
|
||
<>
|
||
<group
|
||
ref={groupRef}
|
||
position={crowdAnchor.position}
|
||
rotation={degreesRotationToRadians(crowdAnchor.rotation)}
|
||
scale={crowdAnchor.scale}
|
||
/>
|
||
<ViewportTransformControls
|
||
mode={transformMode}
|
||
object={groupRef}
|
||
onObjectChange={commitCrowdTransformFromViewport}
|
||
translationSnap={transformMode === "translate" ? translationSnap : null}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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<Group>(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<MouseEvent>) {
|
||
// 打点模式下画布不响应选取(机位也一样),双击地面才落轨迹点
|
||
if (trajectoryPlacementObjectId) {
|
||
event.stopPropagation();
|
||
return;
|
||
}
|
||
|
||
event.stopPropagation();
|
||
selectObject(object?.id ?? null);
|
||
}
|
||
|
||
const node = (
|
||
<group
|
||
ref={groupRef}
|
||
position={camera.transform.position}
|
||
quaternion={cameraQuaternion}
|
||
scale={object?.transform.scale ?? [1, 1, 1]}
|
||
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true, directorCameraId: camera.id }}
|
||
onClick={selectCameraFromViewport}
|
||
>
|
||
{showLabel ? (
|
||
<ViewportObjectLabel position={[0, cameraLabelY, 0]}>{camera.name}</ViewportObjectLabel>
|
||
) : null}
|
||
|
||
<mesh name={`${camera.id}-hit-area`} onClick={selectCameraFromViewport} position={cameraHitArea.position}>
|
||
<boxGeometry args={cameraHitArea.args} />
|
||
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
|
||
</mesh>
|
||
|
||
{bodyWireframeLines.map((line, index) => (
|
||
<Line
|
||
key={`${camera.id}-${line.part}-${index}`}
|
||
color={VIEWPORT_CAMERA_LINE}
|
||
lineWidth={1}
|
||
name={`${camera.id}-${line.part}-${index}`}
|
||
onClick={selectCameraFromViewport}
|
||
opacity={VIEWPORT_CAMERA_LINE_OPACITY}
|
||
points={line.points}
|
||
transparent
|
||
/>
|
||
))}
|
||
|
||
{frustumLines.map((points, index) => (
|
||
<Line
|
||
key={`${camera.id}-frustum-${index}`}
|
||
color={VIEWPORT_CAMERA_LINE}
|
||
lineWidth={1}
|
||
name={`${camera.id}-viewfinder-${index}`}
|
||
onClick={selectCameraFromViewport}
|
||
opacity={VIEWPORT_CAMERA_LINE_OPACITY}
|
||
points={points}
|
||
transparent
|
||
/>
|
||
))}
|
||
</group>
|
||
);
|
||
|
||
if (!selected || !transformable) return node;
|
||
|
||
return (
|
||
<>
|
||
{node}
|
||
<ViewportTransformControls
|
||
mode={transformMode}
|
||
object={groupRef}
|
||
onObjectChange={commitCameraTransformFromViewport}
|
||
translationSnap={transformMode === "translate" ? translationSnap : null}
|
||
editingId={camera.id}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 轨迹打点的拾取平面:不可见但可被射线命中,只在「打点模式」挂载。
|
||
*
|
||
* 落点坐标不依赖「被打中的是哪块面」:直接用场景组自身的 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<Group>;
|
||
}) {
|
||
const addTrajectoryPoint = useDirectorStore((state) => state.addTrajectoryPoint);
|
||
|
||
return (
|
||
<mesh
|
||
name="trajectory-placement-plane"
|
||
position={[0, 0, 0]}
|
||
rotation={[-Math.PI / 2, 0, 0]}
|
||
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
|
||
onDoubleClick={(event) => {
|
||
event.stopPropagation();
|
||
// 用「场景组」自身的 worldToLocal 把世界交点换算成场景组局部坐标,
|
||
// 不依赖被打中的是哪块面(拾取平面 / 地面都绕 X 轴转 -90° 躺平)。
|
||
const group = sceneGroupRef.current;
|
||
if (!group) return;
|
||
const local = group.worldToLocal(event.point.clone());
|
||
addTrajectoryPoint(objectId, getTrajectoryGroundPoint(local));
|
||
}}
|
||
>
|
||
<planeGeometry args={[TRAJECTORY_PLANE_SIZE, TRAJECTORY_PLANE_SIZE]} />
|
||
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
|
||
</mesh>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 一个角色的运动轨迹:每个路点画成贴地实心小球(可点选),相邻路点之间用穿过各点的
|
||
* 平滑曲线(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<Group>(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<MouseEvent>) {
|
||
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 (
|
||
<group name={`trajectory-${owner.id}`} userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}>
|
||
{sampledCurve ? (
|
||
<Line
|
||
color={color}
|
||
lineWidth={1.5}
|
||
name={`trajectory-curve-${owner.id}`}
|
||
points={sampledCurve}
|
||
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
|
||
/>
|
||
) : null}
|
||
{points.map((point) => {
|
||
const selected = point.id === selectedPointId;
|
||
|
||
return (
|
||
<group
|
||
key={point.id}
|
||
ref={selected ? selectedGroupRef : undefined}
|
||
position={[point.position[0], point.position[1] + GROUND_GRID_ELEVATION, point.position[2]]}
|
||
>
|
||
{/* 透明大圆盘只为了让小球更好点中(俯视时点地面圆盘比点小球容易) */}
|
||
<mesh
|
||
rotation={[-Math.PI / 2, 0, 0]}
|
||
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
|
||
onClick={interactive ? (event) => selectPoint(point.id, event) : undefined}
|
||
>
|
||
<circleGeometry args={[0.3, 24]} />
|
||
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
|
||
</mesh>
|
||
{/* 实心小球:选中用高亮色,否则用角色色。
|
||
这里必须写深度(用材质默认值 true,别改成 false):地面是 transparent 材质,
|
||
即使透明度为 1 也走透明通道、排在所有不透明物体之后渲染;
|
||
小球一旦不写深度,地面就会整片盖住它 —— 表现就是「地面不透明时看不到轨迹点」。 */}
|
||
<mesh
|
||
name={`trajectory-point-${point.id}`}
|
||
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
|
||
onClick={interactive ? (event) => selectPoint(point.id, event) : undefined}
|
||
>
|
||
<sphereGeometry args={[0.1, 18, 14]} />
|
||
<meshBasicMaterial color={selected ? TRAJECTORY_SELECTED_COLOR : color} />
|
||
</mesh>
|
||
</group>
|
||
);
|
||
})}
|
||
{/* 选中点挂拖拽 gizmo:拖到哪,角色预览就跟到哪。打点模式 / 播放中 / 无选中点时不挂。 */}
|
||
{draggable ? (
|
||
<ViewportTransformControls
|
||
mode="translate"
|
||
object={selectedGroupRef}
|
||
onObjectChange={commitSelectedPoint}
|
||
translationSnap={translationSnap}
|
||
editingId={owner.id}
|
||
/>
|
||
) : null}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 地面坐标轴:X / Z 两条穿过原点贯通整块地面,颜色与坐标控件(全景球)对应轴一致。
|
||
* 抬到和网格同一高度,避免与地面平面 z-fighting;和网格一样不参与截图/视频导出。
|
||
*/
|
||
function GroundAxes({ y }: { y: number }) {
|
||
return (
|
||
<>
|
||
{getGroundAxisLines(y).map((axis) => (
|
||
<Line
|
||
key={axis.key}
|
||
color={axis.color}
|
||
lineWidth={2}
|
||
name={`ground-axis-${axis.key}`}
|
||
points={axis.points}
|
||
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
|
||
/>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
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<string, boolean>();
|
||
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<Group>(null);
|
||
|
||
function handleObjectSelect(item: DirectorObject) {
|
||
// 打点模式下画布不响应选取:这一下双击只用来给目标角色落一个轨迹点
|
||
if (trajectoryPlacementObjectId) return;
|
||
|
||
if (item.kind === "character" && item.crowdId) {
|
||
selectCrowd(item.crowdId);
|
||
return;
|
||
}
|
||
|
||
selectObject(item.id);
|
||
}
|
||
|
||
return (
|
||
<group ref={sceneGroupRef} {...getSceneGroupTransform(scene)}>
|
||
{scene.showGround ? (
|
||
<>
|
||
<mesh position={[0, scene.groundHeight, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||
<planeGeometry args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]} />
|
||
<meshBasicMaterial
|
||
color={scene.groundColor}
|
||
opacity={getEffectiveGroundOpacity(scene.groundOpacity, Boolean(panoramaAsset))}
|
||
polygonOffset
|
||
polygonOffsetFactor={1}
|
||
polygonOffsetUnits={1}
|
||
side={GROUND_SURFACE_SIDE}
|
||
transparent
|
||
/>
|
||
</mesh>
|
||
<GroundAxes y={scene.groundHeight + GROUND_GRID_ELEVATION} />
|
||
</>
|
||
) : null}
|
||
{/* 打点模式:整块地面变成落点用的拾取面(不可见) */}
|
||
{trajectoryPlacementObjectId ? (
|
||
<TrajectoryPlacementPlane objectId={trajectoryPlacementObjectId} sceneGroupRef={sceneGroupRef} />
|
||
) : null}
|
||
{objects
|
||
.filter((item) => (item.trajectoryPoints ?? []).length > 0)
|
||
.map((item) => (
|
||
<TrajectoryPath
|
||
key={`trajectory-${item.id}`}
|
||
owner={item}
|
||
selectedPointId={selectedTrajectoryPoint?.objectId === item.id ? selectedTrajectoryPoint.pointId : null}
|
||
interactive={!trajectoryPlacementObjectId}
|
||
/>
|
||
))}
|
||
{objects
|
||
.filter((item) => item.visible && item.kind !== "camera")
|
||
.map((item) => {
|
||
const asset = item.assetRefId ? assetsById.get(item.assetRefId) : undefined;
|
||
|
||
return (
|
||
<ObjectSceneNode
|
||
key={item.id}
|
||
asset={asset}
|
||
item={item}
|
||
selected={item.crowdId ? false : item.id === selectedObjectId}
|
||
showLabels={scene.showLabels}
|
||
transformMode={transformMode}
|
||
transformable={!item.locked}
|
||
translationSnap={translationSnap}
|
||
onSelect={handleObjectSelect}
|
||
/>
|
||
);
|
||
})}
|
||
{Array.from(new Set(objects.map((item) => item.crowdId).filter((item): item is string => typeof item === "string"))).map(
|
||
(crowdId) => (
|
||
<CrowdTransformRig
|
||
key={crowdId}
|
||
crowdId={crowdId}
|
||
objects={objects}
|
||
selected={selectedCrowdId === crowdId}
|
||
transformMode={transformMode}
|
||
transformable={!(crowdLocksById.get(crowdId) ?? false)}
|
||
translationSnap={translationSnap}
|
||
/>
|
||
)
|
||
)}
|
||
{viewMode === "director"
|
||
? cameras
|
||
.map((camera) => ({ camera, object: cameraObjectsByCameraId.get(camera.id) }))
|
||
.filter(({ object }) => object?.visible ?? true)
|
||
.map(({ camera, object }) => (
|
||
<ViewportCameraRig
|
||
key={camera.id}
|
||
camera={camera}
|
||
object={object}
|
||
selected={object?.id === selectedObjectId}
|
||
showLabel={scene.showLabels}
|
||
transformMode={transformMode}
|
||
transformable={Boolean(object && !object.locked)}
|
||
translationSnap={translationSnap}
|
||
/>
|
||
))
|
||
: null}
|
||
</group>
|
||
);
|
||
}
|