init
all 视频 视频 视频 视频 视频 init
This commit is contained in:
877
src/editor/canvas/SceneRoot.tsx
Normal file
877
src/editor/canvas/SceneRoot.tsx
Normal file
@@ -0,0 +1,877 @@
|
||||
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 } from "react";
|
||||
import { Box3, 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 {
|
||||
VIEWPORT_CAMERA_ASPECT,
|
||||
VIEWPORT_CAMERA_FRUSTUM_DEPTH,
|
||||
VIEWPORT_CAMERA_VISUAL_SCALE,
|
||||
} from "../schema/cameraGeometry";
|
||||
import { VIEWPORT_OBJECT_LABEL_VERTICAL_GAP } from "../schema/viewportLabels";
|
||||
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 { getCrowdAnchorTransform } from "../store/directorStore";
|
||||
|
||||
export { getEffectiveGroundOpacity, getPanoramaRotationRadians } from "./panoramaMath";
|
||||
|
||||
const VIEWPORT_CAMERA_LINE = "#A9D8FF";
|
||||
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 HIDE_FROM_VIEWPORT_CAPTURE_KEY = "hideFromViewportCapture";
|
||||
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 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;
|
||||
|
||||
updateObjectTransform(item.id, {
|
||||
position: [group.position.x, group.position.y, group.position.z],
|
||||
rotation: [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={item.transform.position}
|
||||
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}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
|
||||
const showGizmo = selected && transformable && !isPlaying;
|
||||
|
||||
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;
|
||||
|
||||
updateCrowdTransform(crowdId, {
|
||||
position: [group.position.x, group.position.y, group.position.z],
|
||||
rotation: [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={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 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>) {
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 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]);
|
||||
|
||||
function handleObjectSelect(item: DirectorObject) {
|
||||
if (item.kind === "character" && item.crowdId) {
|
||||
selectCrowd(item.crowdId);
|
||||
return;
|
||||
}
|
||||
|
||||
selectObject(item.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<group
|
||||
position={scene.position}
|
||||
rotation={scene.rotation}
|
||||
scale={[scene.scale, scene.scale, scene.scale]}
|
||||
>
|
||||
{scene.showGround ? (
|
||||
<mesh position={[0, scene.groundHeight, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[200, 200]} />
|
||||
<meshBasicMaterial
|
||||
color="#303640"
|
||||
opacity={getEffectiveGroundOpacity(scene.groundOpacity, Boolean(panoramaAsset))}
|
||||
polygonOffset
|
||||
polygonOffsetFactor={1}
|
||||
polygonOffsetUnits={1}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
) : null}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user