Files
webeditor3d/src/core/transform-session.ts
Victor Giers 714cef581f auto-git:
[change] src/core/transform-session.ts
2026-04-22 18:24:34 +02:00

1910 lines
52 KiB
TypeScript

import { createOpaqueId } from "./ids";
import {
resolveSelectionActiveId,
type EditorSelection
} from "./selection";
import type { WhiteboxSelectionMode } from "./whitebox-selection-mode";
import type { Vec3 } from "./vector";
import {
cloneBrushGeometry,
createBoxBrush,
createConeBrush,
createRadialPrismBrush,
createTorusBrush,
createWedgeBrush,
type Brush,
type BrushGeometry,
type BrushKind,
type BoxBrushGeometry,
type ConeBrushGeometry,
type RadialPrismBrushGeometry,
type TorusBrushGeometry,
type WedgeBrushGeometry,
type WhiteboxEdgeId,
type WhiteboxFaceId,
type WhiteboxVertexId
} from "../document/brushes";
import { getScenePathPoint } from "../document/paths";
import type { SceneDocument } from "../document/scene-document";
import {
cloneEntityInstance,
getEntityKindLabel,
type EntityInstance,
type EntityKind
} from "../entities/entity-instances";
import {
cloneModelInstance,
getModelInstanceKindLabel
} from "../assets/model-instances";
import type { ViewportPanelId } from "../viewport-three/viewport-layout";
import {
getBrushEdgeLabel,
getBrushFaceLabel,
getBrushKindLabel,
getBrushVertexLabel
} from "../geometry/whitebox-topology";
import {
getBrushEdgeAxis,
getBrushEdgeScaleAxes,
getBrushFaceAxis
} from "../geometry/whitebox-brush";
export type TransformOperation = "translate" | "rotate" | "scale";
export type TransformAxis = "x" | "y" | "z";
export type TransformAxisSpace = "world" | "local";
export type TransformSessionSource = "keyboard" | "gizmo" | "toolbar";
export interface YawEntityRotationState {
kind: "yaw";
yawDegrees: number;
}
export interface DirectionEntityRotationState {
kind: "direction";
direction: Vec3;
}
export interface NoEntityRotationState {
kind: "none";
}
export type EntityTransformRotationState =
| NoEntityRotationState
| YawEntityRotationState
| DirectionEntityRotationState;
export interface BrushTransformTarget {
kind: "brush";
brushId: string;
brushKind: BrushKind;
sideCount?: number;
majorSegmentCount?: number;
tubeSegmentCount?: number;
initialCenter: Vec3;
initialRotationDegrees: Vec3;
initialSize: Vec3;
initialGeometry: BrushGeometry;
}
export interface BrushFaceTransformTarget {
kind: "brushFace";
brushId: string;
brushKind: BrushKind;
sideCount?: number;
majorSegmentCount?: number;
tubeSegmentCount?: number;
faceId: WhiteboxFaceId;
initialCenter: Vec3;
initialRotationDegrees: Vec3;
initialSize: Vec3;
initialGeometry: BrushGeometry;
}
export interface BrushEdgeTransformTarget {
kind: "brushEdge";
brushId: string;
brushKind: BrushKind;
sideCount?: number;
majorSegmentCount?: number;
tubeSegmentCount?: number;
edgeId: WhiteboxEdgeId;
initialCenter: Vec3;
initialRotationDegrees: Vec3;
initialSize: Vec3;
initialGeometry: BrushGeometry;
}
export interface BrushVertexTransformTarget {
kind: "brushVertex";
brushId: string;
brushKind: BrushKind;
sideCount?: number;
majorSegmentCount?: number;
tubeSegmentCount?: number;
vertexId: WhiteboxVertexId;
initialCenter: Vec3;
initialRotationDegrees: Vec3;
initialSize: Vec3;
initialGeometry: BrushGeometry;
}
export interface ModelInstanceTransformTarget {
kind: "modelInstance";
modelInstanceId: string;
assetId: string;
initialPosition: Vec3;
initialRotationDegrees: Vec3;
initialScale: Vec3;
}
export interface PathPointTransformTarget {
kind: "pathPoint";
pathId: string;
pointId: string;
initialPosition: Vec3;
}
export interface EntityTransformTarget {
kind: "entity";
entityId: string;
entityKind: EntityKind;
initialPosition: Vec3;
initialRotation: EntityTransformRotationState;
}
export interface BrushesTransformTarget {
kind: "brushes";
activeBrushId: string;
initialPivot: Vec3;
items: BrushTransformTarget[];
}
export interface ModelInstancesTransformTarget {
kind: "modelInstances";
activeModelInstanceId: string;
initialPivot: Vec3;
items: ModelInstanceTransformTarget[];
}
export interface EntitiesTransformTarget {
kind: "entities";
activeEntityId: string;
initialPivot: Vec3;
items: EntityTransformTarget[];
}
export type TransformTarget =
| BrushTransformTarget
| BrushesTransformTarget
| BrushFaceTransformTarget
| BrushEdgeTransformTarget
| BrushVertexTransformTarget
| ModelInstanceTransformTarget
| ModelInstancesTransformTarget
| PathPointTransformTarget
| EntityTransformTarget
| EntitiesTransformTarget;
export interface BrushTransformPreview {
kind: "brush";
center: Vec3;
rotationDegrees: Vec3;
size: Vec3;
geometry: BrushGeometry;
}
function areBrushGeometriesEqual(
left: BrushGeometry,
right: BrushGeometry
): boolean {
const leftVertexIds = Object.keys(left.vertices);
const rightVertexIds = Object.keys(right.vertices);
if (leftVertexIds.length !== rightVertexIds.length) {
return false;
}
return leftVertexIds.every((vertexId) => {
const leftVertex = left.vertices[vertexId];
const rightVertex = right.vertices[vertexId];
return rightVertex !== undefined && areVec3Equal(leftVertex, rightVertex);
});
}
export interface ModelInstanceTransformPreview {
kind: "modelInstance";
position: Vec3;
rotationDegrees: Vec3;
scale: Vec3;
}
export interface PathPointTransformPreview {
kind: "pathPoint";
position: Vec3;
}
export interface EntityTransformPreview {
kind: "entity";
position: Vec3;
rotation: EntityTransformRotationState;
}
export interface BrushTransformPreviewItem {
brushId: string;
center: Vec3;
rotationDegrees: Vec3;
size: Vec3;
geometry: BrushGeometry;
}
export interface BrushesTransformPreview {
kind: "brushes";
pivot: Vec3;
items: BrushTransformPreviewItem[];
}
export interface ModelInstanceTransformPreviewItem {
modelInstanceId: string;
position: Vec3;
rotationDegrees: Vec3;
scale: Vec3;
}
export interface ModelInstancesTransformPreview {
kind: "modelInstances";
pivot: Vec3;
items: ModelInstanceTransformPreviewItem[];
}
export interface EntityTransformPreviewItem {
entityId: string;
position: Vec3;
rotation: EntityTransformRotationState;
}
export interface EntitiesTransformPreview {
kind: "entities";
pivot: Vec3;
items: EntityTransformPreviewItem[];
}
export type TransformPreview =
| BrushTransformPreview
| BrushesTransformPreview
| ModelInstanceTransformPreview
| ModelInstancesTransformPreview
| PathPointTransformPreview
| EntityTransformPreview
| EntitiesTransformPreview;
export interface ActiveTransformSession {
kind: "active";
id: string;
source: TransformSessionSource;
sourcePanelId: ViewportPanelId;
operation: TransformOperation;
surfaceSnapEnabled: boolean;
axisConstraint: TransformAxis | null;
axisConstraintSpace: TransformAxisSpace;
target: TransformTarget;
preview: TransformPreview;
}
export type TransformSessionState = ActiveTransformSession | { kind: "none" };
export interface TransformTargetResolution {
target: TransformTarget | null;
message: string | null;
}
function cloneVec3(vector: Vec3): Vec3 {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function areVec3Equal(left: Vec3, right: Vec3): boolean {
return left.x === right.x && left.y === right.y && left.z === right.z;
}
function averageVec3(vectors: readonly Vec3[]): Vec3 {
if (vectors.length === 0) {
return {
x: 0,
y: 0,
z: 0
};
}
const totals = vectors.reduce(
(accumulator, vector) => ({
x: accumulator.x + vector.x,
y: accumulator.y + vector.y,
z: accumulator.z + vector.z
}),
{
x: 0,
y: 0,
z: 0
}
);
return {
x: totals.x / vectors.length,
y: totals.y / vectors.length,
z: totals.z / vectors.length
};
}
function cloneEntityTransformRotationState(
rotation: EntityTransformRotationState
): EntityTransformRotationState {
switch (rotation.kind) {
case "none":
return {
kind: "none"
};
case "yaw":
return {
kind: "yaw",
yawDegrees: rotation.yawDegrees
};
case "direction":
return {
kind: "direction",
direction: cloneVec3(rotation.direction)
};
}
}
function cloneBrushTransformPreviewItem(
preview: BrushTransformPreviewItem
): BrushTransformPreviewItem {
return {
brushId: preview.brushId,
center: cloneVec3(preview.center),
rotationDegrees: cloneVec3(preview.rotationDegrees),
size: cloneVec3(preview.size),
geometry: cloneBrushGeometry(preview.geometry)
};
}
function cloneModelInstanceTransformPreviewItem(
preview: ModelInstanceTransformPreviewItem
): ModelInstanceTransformPreviewItem {
return {
modelInstanceId: preview.modelInstanceId,
position: cloneVec3(preview.position),
rotationDegrees: cloneVec3(preview.rotationDegrees),
scale: cloneVec3(preview.scale)
};
}
function cloneEntityTransformPreviewItem(
preview: EntityTransformPreviewItem
): EntityTransformPreviewItem {
return {
entityId: preview.entityId,
position: cloneVec3(preview.position),
rotation: cloneEntityTransformRotationState(preview.rotation)
};
}
function areBrushTransformPreviewItemsEqual(
left: BrushTransformPreviewItem,
right: BrushTransformPreviewItem
): boolean {
return (
left.brushId === right.brushId &&
areVec3Equal(left.center, right.center) &&
areVec3Equal(left.rotationDegrees, right.rotationDegrees) &&
areVec3Equal(left.size, right.size) &&
areBrushGeometriesEqual(left.geometry, right.geometry)
);
}
function areModelInstanceTransformPreviewItemsEqual(
left: ModelInstanceTransformPreviewItem,
right: ModelInstanceTransformPreviewItem
): boolean {
return (
left.modelInstanceId === right.modelInstanceId &&
areVec3Equal(left.position, right.position) &&
areVec3Equal(left.rotationDegrees, right.rotationDegrees) &&
areVec3Equal(left.scale, right.scale)
);
}
function areEntityTransformPreviewItemsEqual(
left: EntityTransformPreviewItem,
right: EntityTransformPreviewItem
): boolean {
return (
left.entityId === right.entityId &&
areVec3Equal(left.position, right.position) &&
areEntityTransformRotationsEqual(left.rotation, right.rotation)
);
}
function createBrushSnapshotFromTarget(
target:
| BrushTransformTarget
| BrushFaceTransformTarget
| BrushEdgeTransformTarget
| BrushVertexTransformTarget
): Brush {
switch (target.brushKind) {
case "box":
return createBoxBrush({
id: target.brushId,
center: target.initialCenter,
rotationDegrees: target.initialRotationDegrees,
size: target.initialSize,
geometry: target.initialGeometry as BoxBrushGeometry
});
case "wedge":
return createWedgeBrush({
id: target.brushId,
center: target.initialCenter,
rotationDegrees: target.initialRotationDegrees,
size: target.initialSize,
geometry: target.initialGeometry as WedgeBrushGeometry
});
case "radialPrism":
return createRadialPrismBrush({
id: target.brushId,
center: target.initialCenter,
rotationDegrees: target.initialRotationDegrees,
size: target.initialSize,
sideCount: target.sideCount,
geometry: target.initialGeometry as RadialPrismBrushGeometry
});
case "cone":
return createConeBrush({
id: target.brushId,
center: target.initialCenter,
rotationDegrees: target.initialRotationDegrees,
size: target.initialSize,
sideCount: target.sideCount,
geometry: target.initialGeometry as ConeBrushGeometry
});
case "torus":
return createTorusBrush({
id: target.brushId,
center: target.initialCenter,
rotationDegrees: target.initialRotationDegrees,
size: target.initialSize,
majorSegmentCount: target.majorSegmentCount,
tubeSegmentCount: target.tubeSegmentCount,
geometry: target.initialGeometry as TorusBrushGeometry
});
}
}
function areEntityTransformRotationsEqual(
left: EntityTransformRotationState,
right: EntityTransformRotationState
): boolean {
if (left.kind !== right.kind) {
return false;
}
switch (left.kind) {
case "none":
return true;
case "yaw":
return right.kind === "yaw" && left.yawDegrees === right.yawDegrees;
case "direction":
return (
right.kind === "direction" &&
areVec3Equal(left.direction, right.direction)
);
}
}
export function createInactiveTransformSession(): TransformSessionState {
return {
kind: "none"
};
}
export function cloneTransformTarget(target: TransformTarget): TransformTarget {
switch (target.kind) {
case "brush":
return {
kind: "brush",
brushId: target.brushId,
brushKind: target.brushKind,
sideCount: target.sideCount,
majorSegmentCount: target.majorSegmentCount,
tubeSegmentCount: target.tubeSegmentCount,
initialCenter: cloneVec3(target.initialCenter),
initialRotationDegrees: cloneVec3(target.initialRotationDegrees),
initialSize: cloneVec3(target.initialSize),
initialGeometry: cloneBrushGeometry(target.initialGeometry)
};
case "brushes":
return {
kind: "brushes",
activeBrushId: target.activeBrushId,
initialPivot: cloneVec3(target.initialPivot),
items: target.items.map(
(item) => cloneTransformTarget(item) as BrushTransformTarget
)
};
case "brushFace":
return {
kind: "brushFace",
brushId: target.brushId,
brushKind: target.brushKind,
sideCount: target.sideCount,
majorSegmentCount: target.majorSegmentCount,
tubeSegmentCount: target.tubeSegmentCount,
faceId: target.faceId,
initialCenter: cloneVec3(target.initialCenter),
initialRotationDegrees: cloneVec3(target.initialRotationDegrees),
initialSize: cloneVec3(target.initialSize),
initialGeometry: cloneBrushGeometry(target.initialGeometry)
};
case "brushEdge":
return {
kind: "brushEdge",
brushId: target.brushId,
brushKind: target.brushKind,
sideCount: target.sideCount,
majorSegmentCount: target.majorSegmentCount,
tubeSegmentCount: target.tubeSegmentCount,
edgeId: target.edgeId,
initialCenter: cloneVec3(target.initialCenter),
initialRotationDegrees: cloneVec3(target.initialRotationDegrees),
initialSize: cloneVec3(target.initialSize),
initialGeometry: cloneBrushGeometry(target.initialGeometry)
};
case "brushVertex":
return {
kind: "brushVertex",
brushId: target.brushId,
brushKind: target.brushKind,
sideCount: target.sideCount,
majorSegmentCount: target.majorSegmentCount,
tubeSegmentCount: target.tubeSegmentCount,
vertexId: target.vertexId,
initialCenter: cloneVec3(target.initialCenter),
initialRotationDegrees: cloneVec3(target.initialRotationDegrees),
initialSize: cloneVec3(target.initialSize),
initialGeometry: cloneBrushGeometry(target.initialGeometry)
};
case "modelInstance":
return {
kind: "modelInstance",
modelInstanceId: target.modelInstanceId,
assetId: target.assetId,
initialPosition: cloneVec3(target.initialPosition),
initialRotationDegrees: cloneVec3(target.initialRotationDegrees),
initialScale: cloneVec3(target.initialScale)
};
case "modelInstances":
return {
kind: "modelInstances",
activeModelInstanceId: target.activeModelInstanceId,
initialPivot: cloneVec3(target.initialPivot),
items: target.items.map(
(item) => cloneTransformTarget(item) as ModelInstanceTransformTarget
)
};
case "pathPoint":
return {
kind: "pathPoint",
pathId: target.pathId,
pointId: target.pointId,
initialPosition: cloneVec3(target.initialPosition)
};
case "entity":
return {
kind: "entity",
entityId: target.entityId,
entityKind: target.entityKind,
initialPosition: cloneVec3(target.initialPosition),
initialRotation: cloneEntityTransformRotationState(
target.initialRotation
)
};
case "entities":
return {
kind: "entities",
activeEntityId: target.activeEntityId,
initialPivot: cloneVec3(target.initialPivot),
items: target.items.map(
(item) => cloneTransformTarget(item) as EntityTransformTarget
)
};
}
}
export function cloneTransformPreview(
preview: TransformPreview
): TransformPreview {
switch (preview.kind) {
case "brush":
return {
kind: "brush",
center: cloneVec3(preview.center),
rotationDegrees: cloneVec3(preview.rotationDegrees),
size: cloneVec3(preview.size),
geometry: cloneBrushGeometry(preview.geometry)
};
case "brushes":
return {
kind: "brushes",
pivot: cloneVec3(preview.pivot),
items: preview.items.map(cloneBrushTransformPreviewItem)
};
case "modelInstance":
return {
kind: "modelInstance",
position: cloneVec3(preview.position),
rotationDegrees: cloneVec3(preview.rotationDegrees),
scale: cloneVec3(preview.scale)
};
case "modelInstances":
return {
kind: "modelInstances",
pivot: cloneVec3(preview.pivot),
items: preview.items.map(cloneModelInstanceTransformPreviewItem)
};
case "pathPoint":
return {
kind: "pathPoint",
position: cloneVec3(preview.position)
};
case "entity":
return {
kind: "entity",
position: cloneVec3(preview.position),
rotation: cloneEntityTransformRotationState(preview.rotation)
};
case "entities":
return {
kind: "entities",
pivot: cloneVec3(preview.pivot),
items: preview.items.map(cloneEntityTransformPreviewItem)
};
}
}
export function cloneTransformSession(
session: TransformSessionState
): TransformSessionState {
if (session.kind === "none") {
return session;
}
return {
kind: "active",
id: session.id,
source: session.source,
sourcePanelId: session.sourcePanelId,
operation: session.operation,
surfaceSnapEnabled: session.surfaceSnapEnabled,
axisConstraint: session.axisConstraint,
axisConstraintSpace: session.axisConstraintSpace,
target: cloneTransformTarget(session.target),
preview: cloneTransformPreview(session.preview)
};
}
export function areTransformSessionsEqual(
left: TransformSessionState,
right: TransformSessionState
): boolean {
if (left.kind !== right.kind) {
return false;
}
if (left.kind === "none" || right.kind === "none") {
return true;
}
return (
left.id === right.id &&
left.source === right.source &&
left.sourcePanelId === right.sourcePanelId &&
left.operation === right.operation &&
left.surfaceSnapEnabled === right.surfaceSnapEnabled &&
left.axisConstraint === right.axisConstraint &&
left.axisConstraintSpace === right.axisConstraintSpace &&
areTransformTargetsEqual(left.target, right.target) &&
areTransformPreviewsEqual(left.preview, right.preview)
);
}
function areTransformTargetsEqual(
left: TransformTarget,
right: TransformTarget
): boolean {
if (left.kind !== right.kind) {
return false;
}
switch (left.kind) {
case "brush":
return (
right.kind === "brush" &&
left.brushId === right.brushId &&
left.brushKind === right.brushKind &&
left.sideCount === right.sideCount &&
left.majorSegmentCount === right.majorSegmentCount &&
left.tubeSegmentCount === right.tubeSegmentCount &&
areVec3Equal(left.initialCenter, right.initialCenter) &&
areVec3Equal(
left.initialRotationDegrees,
right.initialRotationDegrees
) &&
areVec3Equal(left.initialSize, right.initialSize) &&
areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry)
);
case "brushes":
return (
right.kind === "brushes" &&
left.activeBrushId === right.activeBrushId &&
areVec3Equal(left.initialPivot, right.initialPivot) &&
left.items.length === right.items.length &&
left.items.every((item, index) =>
areTransformTargetsEqual(item, right.items[index])
)
);
case "brushFace":
return (
right.kind === "brushFace" &&
left.brushId === right.brushId &&
left.brushKind === right.brushKind &&
left.sideCount === right.sideCount &&
left.majorSegmentCount === right.majorSegmentCount &&
left.tubeSegmentCount === right.tubeSegmentCount &&
left.faceId === right.faceId &&
areVec3Equal(left.initialCenter, right.initialCenter) &&
areVec3Equal(
left.initialRotationDegrees,
right.initialRotationDegrees
) &&
areVec3Equal(left.initialSize, right.initialSize) &&
areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry)
);
case "brushEdge":
return (
right.kind === "brushEdge" &&
left.brushId === right.brushId &&
left.brushKind === right.brushKind &&
left.sideCount === right.sideCount &&
left.majorSegmentCount === right.majorSegmentCount &&
left.tubeSegmentCount === right.tubeSegmentCount &&
left.edgeId === right.edgeId &&
areVec3Equal(left.initialCenter, right.initialCenter) &&
areVec3Equal(
left.initialRotationDegrees,
right.initialRotationDegrees
) &&
areVec3Equal(left.initialSize, right.initialSize) &&
areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry)
);
case "brushVertex":
return (
right.kind === "brushVertex" &&
left.brushId === right.brushId &&
left.brushKind === right.brushKind &&
left.sideCount === right.sideCount &&
left.majorSegmentCount === right.majorSegmentCount &&
left.tubeSegmentCount === right.tubeSegmentCount &&
left.vertexId === right.vertexId &&
areVec3Equal(left.initialCenter, right.initialCenter) &&
areVec3Equal(
left.initialRotationDegrees,
right.initialRotationDegrees
) &&
areVec3Equal(left.initialSize, right.initialSize) &&
areBrushGeometriesEqual(left.initialGeometry, right.initialGeometry)
);
case "modelInstance":
return (
right.kind === "modelInstance" &&
left.modelInstanceId === right.modelInstanceId &&
left.assetId === right.assetId &&
areVec3Equal(left.initialPosition, right.initialPosition) &&
areVec3Equal(
left.initialRotationDegrees,
right.initialRotationDegrees
) &&
areVec3Equal(left.initialScale, right.initialScale)
);
case "modelInstances":
return (
right.kind === "modelInstances" &&
left.activeModelInstanceId === right.activeModelInstanceId &&
areVec3Equal(left.initialPivot, right.initialPivot) &&
left.items.length === right.items.length &&
left.items.every((item, index) =>
areTransformTargetsEqual(item, right.items[index])
)
);
case "pathPoint":
return (
right.kind === "pathPoint" &&
left.pathId === right.pathId &&
left.pointId === right.pointId &&
areVec3Equal(left.initialPosition, right.initialPosition)
);
case "entity":
return (
right.kind === "entity" &&
left.entityId === right.entityId &&
left.entityKind === right.entityKind &&
areVec3Equal(left.initialPosition, right.initialPosition) &&
areEntityTransformRotationsEqual(
left.initialRotation,
right.initialRotation
)
);
case "entities":
return (
right.kind === "entities" &&
left.activeEntityId === right.activeEntityId &&
areVec3Equal(left.initialPivot, right.initialPivot) &&
left.items.length === right.items.length &&
left.items.every((item, index) =>
areTransformTargetsEqual(item, right.items[index])
)
);
}
}
function areTransformPreviewsEqual(
left: TransformPreview,
right: TransformPreview
): boolean {
if (left.kind !== right.kind) {
return false;
}
switch (left.kind) {
case "brush":
return (
right.kind === "brush" &&
areVec3Equal(left.center, right.center) &&
areVec3Equal(left.rotationDegrees, right.rotationDegrees) &&
areVec3Equal(left.size, right.size) &&
areBrushGeometriesEqual(left.geometry, right.geometry)
);
case "brushes":
return (
right.kind === "brushes" &&
areVec3Equal(left.pivot, right.pivot) &&
left.items.length === right.items.length &&
left.items.every((item, index) =>
areBrushTransformPreviewItemsEqual(item, right.items[index])
)
);
case "modelInstance":
return (
right.kind === "modelInstance" &&
areVec3Equal(left.position, right.position) &&
areVec3Equal(left.rotationDegrees, right.rotationDegrees) &&
areVec3Equal(left.scale, right.scale)
);
case "modelInstances":
return (
right.kind === "modelInstances" &&
areVec3Equal(left.pivot, right.pivot) &&
left.items.length === right.items.length &&
left.items.every((item, index) =>
areModelInstanceTransformPreviewItemsEqual(item, right.items[index])
)
);
case "pathPoint":
return (
right.kind === "pathPoint" &&
areVec3Equal(left.position, right.position)
);
case "entity":
return (
right.kind === "entity" &&
areVec3Equal(left.position, right.position) &&
areEntityTransformRotationsEqual(left.rotation, right.rotation)
);
case "entities":
return (
right.kind === "entities" &&
areVec3Equal(left.pivot, right.pivot) &&
left.items.length === right.items.length &&
left.items.every((item, index) =>
areEntityTransformPreviewItemsEqual(item, right.items[index])
)
);
}
}
export function createTransformSession(options: {
source: TransformSessionSource;
sourcePanelId: ViewportPanelId;
operation: TransformOperation;
surfaceSnapEnabled?: boolean;
axisConstraint?: TransformAxis | null;
axisConstraintSpace?: TransformAxisSpace;
target: TransformTarget;
}): ActiveTransformSession {
return {
kind: "active",
id: createOpaqueId("transform-session"),
source: options.source,
sourcePanelId: options.sourcePanelId,
operation: options.operation,
surfaceSnapEnabled: options.surfaceSnapEnabled ?? false,
axisConstraint: options.axisConstraint ?? null,
axisConstraintSpace: options.axisConstraintSpace ?? "world",
target: cloneTransformTarget(options.target),
preview: createTransformPreviewFromTarget(options.target)
};
}
export function createTransformPreviewFromTarget(
target: TransformTarget
): TransformPreview {
switch (target.kind) {
case "brush":
case "brushFace":
case "brushEdge":
case "brushVertex":
return {
kind: "brush",
center: cloneVec3(target.initialCenter),
rotationDegrees: cloneVec3(target.initialRotationDegrees),
size: cloneVec3(target.initialSize),
geometry: cloneBrushGeometry(target.initialGeometry)
};
case "brushes":
return {
kind: "brushes",
pivot: cloneVec3(target.initialPivot),
items: target.items.map((item) => ({
brushId: item.brushId,
center: cloneVec3(item.initialCenter),
rotationDegrees: cloneVec3(item.initialRotationDegrees),
size: cloneVec3(item.initialSize),
geometry: cloneBrushGeometry(item.initialGeometry)
}))
};
case "modelInstance":
return {
kind: "modelInstance",
position: cloneVec3(target.initialPosition),
rotationDegrees: cloneVec3(target.initialRotationDegrees),
scale: cloneVec3(target.initialScale)
};
case "modelInstances":
return {
kind: "modelInstances",
pivot: cloneVec3(target.initialPivot),
items: target.items.map((item) => ({
modelInstanceId: item.modelInstanceId,
position: cloneVec3(item.initialPosition),
rotationDegrees: cloneVec3(item.initialRotationDegrees),
scale: cloneVec3(item.initialScale)
}))
};
case "pathPoint":
return {
kind: "pathPoint",
position: cloneVec3(target.initialPosition)
};
case "entity":
return {
kind: "entity",
position: cloneVec3(target.initialPosition),
rotation: cloneEntityTransformRotationState(target.initialRotation)
};
case "entities":
return {
kind: "entities",
pivot: cloneVec3(target.initialPivot),
items: target.items.map((item) => ({
entityId: item.entityId,
position: cloneVec3(item.initialPosition),
rotation: cloneEntityTransformRotationState(item.initialRotation)
}))
};
}
}
export function doesTransformSessionChangeTarget(
session: ActiveTransformSession
): boolean {
switch (session.target.kind) {
case "brush":
case "brushFace":
case "brushEdge":
case "brushVertex":
return (
session.preview.kind === "brush" &&
(!areVec3Equal(session.preview.center, session.target.initialCenter) ||
!areVec3Equal(
session.preview.rotationDegrees,
session.target.initialRotationDegrees
) ||
!areVec3Equal(session.preview.size, session.target.initialSize) ||
!areBrushGeometriesEqual(
session.preview.geometry,
session.target.initialGeometry
))
);
case "brushes":
const brushesTarget = session.target;
return (
session.preview.kind === "brushes" &&
(!areVec3Equal(session.preview.pivot, brushesTarget.initialPivot) ||
session.preview.items.length !== brushesTarget.items.length ||
session.preview.items.some((item, index) => {
const targetItem = brushesTarget.items[index];
return (
item.brushId !== targetItem.brushId ||
!areVec3Equal(item.center, targetItem.initialCenter) ||
!areVec3Equal(
item.rotationDegrees,
targetItem.initialRotationDegrees
) ||
!areVec3Equal(item.size, targetItem.initialSize) ||
!areBrushGeometriesEqual(item.geometry, targetItem.initialGeometry)
);
}))
);
case "modelInstance":
return (
session.preview.kind === "modelInstance" &&
(!areVec3Equal(
session.preview.position,
session.target.initialPosition
) ||
!areVec3Equal(
session.preview.rotationDegrees,
session.target.initialRotationDegrees
) ||
!areVec3Equal(session.preview.scale, session.target.initialScale))
);
case "modelInstances":
const modelInstancesTarget = session.target;
return (
session.preview.kind === "modelInstances" &&
(!areVec3Equal(
session.preview.pivot,
modelInstancesTarget.initialPivot
) ||
session.preview.items.length !== modelInstancesTarget.items.length ||
session.preview.items.some((item, index) => {
const targetItem = modelInstancesTarget.items[index];
return (
item.modelInstanceId !== targetItem.modelInstanceId ||
!areVec3Equal(item.position, targetItem.initialPosition) ||
!areVec3Equal(
item.rotationDegrees,
targetItem.initialRotationDegrees
) ||
!areVec3Equal(item.scale, targetItem.initialScale)
);
}))
);
case "pathPoint":
return (
session.preview.kind === "pathPoint" &&
!areVec3Equal(
session.preview.position,
session.target.initialPosition
)
);
case "entity":
return (
session.preview.kind === "entity" &&
(!areVec3Equal(
session.preview.position,
session.target.initialPosition
) ||
!areEntityTransformRotationsEqual(
session.preview.rotation,
session.target.initialRotation
))
);
case "entities":
const entitiesTarget = session.target;
return (
session.preview.kind === "entities" &&
(!areVec3Equal(session.preview.pivot, entitiesTarget.initialPivot) ||
session.preview.items.length !== entitiesTarget.items.length ||
session.preview.items.some((item, index) => {
const targetItem = entitiesTarget.items[index];
return (
item.entityId !== targetItem.entityId ||
!areVec3Equal(item.position, targetItem.initialPosition) ||
!areEntityTransformRotationsEqual(
item.rotation,
targetItem.initialRotation
)
);
}))
);
}
}
export function getTransformOperationLabel(
operation: TransformOperation
): string {
switch (operation) {
case "translate":
return "Move";
case "rotate":
return "Rotate";
case "scale":
return "Scale";
}
}
export function getTransformAxisLabel(axis: TransformAxis): string {
return axis.toUpperCase();
}
export function getTransformAxisSpaceLabel(
axisSpace: TransformAxisSpace
): string {
switch (axisSpace) {
case "world":
return "World";
case "local":
return "Local";
}
}
export function getTransformTargetLabel(target: TransformTarget): string {
switch (target.kind) {
case "brush":
return getBrushKindLabel(createBrushSnapshotFromTarget(target));
case "brushes":
return `${target.items.length} Whitebox Solid${target.items.length === 1 ? "" : "s"}`;
case "brushFace":
return `Whitebox Face (${getBrushFaceLabel(createBrushSnapshotFromTarget(target), target.faceId)})`;
case "brushEdge":
return `Whitebox Edge (${getBrushEdgeLabel(createBrushSnapshotFromTarget(target), target.edgeId)})`;
case "brushVertex":
return `Whitebox Vertex (${getBrushVertexLabel(createBrushSnapshotFromTarget(target), target.vertexId)})`;
case "modelInstance":
return getModelInstanceKindLabel();
case "modelInstances":
return `${target.items.length} ${getModelInstanceKindLabel()}${target.items.length === 1 ? "" : "s"}`;
case "pathPoint":
return "Path Point";
case "entity":
return getEntityKindLabel(target.entityKind);
case "entities":
return `${target.items.length} Entit${target.items.length === 1 ? "y" : "ies"}`;
}
}
export function getSupportedTransformOperations(
target: TransformTarget
): TransformOperation[] {
switch (target.kind) {
case "brush":
case "brushes":
case "brushFace":
case "brushEdge":
return ["translate", "rotate", "scale"];
case "brushVertex":
case "pathPoint":
return ["translate"];
case "modelInstance":
case "modelInstances":
return ["translate", "rotate", "scale"];
case "entity":
return target.initialRotation.kind === "none"
? ["translate"]
: ["translate", "rotate"];
case "entities":
return target.items.every(
(item) => getSupportedTransformOperations(item).includes("rotate")
)
? ["translate", "rotate"]
: ["translate"];
}
}
export function supportsTransformOperation(
target: TransformTarget,
operation: TransformOperation
): boolean {
return getSupportedTransformOperations(target).includes(operation);
}
export function supportsTransformSurfaceSnapTarget(
target: TransformTarget
): boolean {
switch (target.kind) {
case "brush":
case "brushes":
case "modelInstance":
case "modelInstances":
return true;
case "entity":
return target.entityKind === "triggerVolume";
case "entities":
return target.items.every((item) => item.entityKind === "triggerVolume");
case "brushFace":
case "brushEdge":
case "brushVertex":
case "pathPoint":
return false;
}
}
export function supportsTransformAxisConstraint(
session: ActiveTransformSession,
axis: TransformAxis
): boolean {
const brushTarget =
session.target.kind === "brush" ||
session.target.kind === "brushes" ||
session.target.kind === "brushFace" ||
session.target.kind === "brushEdge" ||
session.target.kind === "brushVertex"
? session.target.kind === "brushes"
? createBrushSnapshotFromTarget(session.target.items[0])
: createBrushSnapshotFromTarget(session.target)
: null;
switch (session.operation) {
case "translate":
return true;
case "scale":
if (
session.target.kind === "modelInstance" ||
session.target.kind === "modelInstances" ||
session.target.kind === "brush"
||
session.target.kind === "brushes"
) {
return true;
}
if (
session.target.kind === "brushVertex" ||
session.target.kind === "pathPoint"
) {
return false;
}
if (session.target.kind === "brushFace") {
if (brushTarget === null) {
return false;
}
return axis === getBrushFaceAxis(brushTarget, session.target.faceId);
}
if (session.target.kind === "brushEdge") {
if (brushTarget === null) {
return false;
}
return getBrushEdgeScaleAxes(brushTarget, session.target.edgeId).includes(
axis
);
}
return false;
case "rotate":
if (
session.target.kind === "entity" &&
session.target.initialRotation.kind === "yaw"
) {
return axis === "y";
}
if (session.target.kind === "entities") {
return session.target.items.every((item) =>
item.initialRotation.kind === "none"
? false
: item.initialRotation.kind === "yaw"
? axis === "y"
: true
);
}
if (session.target.kind === "brushFace") {
if (brushTarget === null) {
return false;
}
return axis === getBrushFaceAxis(brushTarget, session.target.faceId);
}
if (session.target.kind === "brushEdge") {
if (brushTarget === null) {
return false;
}
return axis === getBrushEdgeAxis(brushTarget, session.target.edgeId);
}
if (session.target.kind === "brushVertex") {
return false;
}
return true;
}
}
export function supportsLocalTransformAxisConstraint(
session: ActiveTransformSession,
axis: TransformAxis
): boolean {
if (!supportsTransformAxisConstraint(session, axis)) {
return false;
}
if (session.operation === "scale") {
return false;
}
switch (session.target.kind) {
case "brush":
case "brushes":
case "modelInstance":
case "modelInstances":
return true;
case "entity":
return session.target.initialRotation.kind !== "none";
case "entities":
return session.target.items.every(
(item) => item.initialRotation.kind !== "none"
);
case "brushFace":
case "brushEdge":
case "brushVertex":
return session.operation === "translate";
case "pathPoint":
return false;
}
}
function resolveEntityRotation(
entity: EntityInstance
): EntityTransformRotationState {
switch (entity.kind) {
case "cameraRig":
return {
kind: "none"
};
case "playerStart":
case "sceneEntry":
case "npc":
case "teleportTarget":
return {
kind: "yaw",
yawDegrees: entity.yawDegrees
};
case "spotLight":
return {
kind: "direction",
direction: cloneVec3(entity.direction)
};
case "pointLight":
case "soundEmitter":
case "triggerVolume":
case "interactable":
return {
kind: "none"
};
}
}
function createBrushTransformTarget(
document: SceneDocument,
brushId: string
): TransformTargetResolution {
const brush = document.brushes[brushId];
if (brush === undefined) {
return {
target: null,
message: "Select a supported whitebox solid before transforming it."
};
}
return {
target: {
kind: "brush",
brushId: brush.id,
brushKind: brush.kind,
sideCount:
brush.kind === "radialPrism" || brush.kind === "cone"
? brush.sideCount
: undefined,
majorSegmentCount:
brush.kind === "torus" ? brush.majorSegmentCount : undefined,
tubeSegmentCount:
brush.kind === "torus" ? brush.tubeSegmentCount : undefined,
initialCenter: cloneVec3(brush.center),
initialRotationDegrees: cloneVec3(brush.rotationDegrees),
initialSize: cloneVec3(brush.size),
initialGeometry: cloneBrushGeometry(brush.geometry)
},
message: null
};
}
function resolveActiveTransformTargetId(
ids: readonly string[],
activeSelectionId: string | null
): string | null {
if (activeSelectionId !== null && ids.includes(activeSelectionId)) {
return activeSelectionId;
}
return ids.at(-1) ?? null;
}
function createBrushesTransformTarget(
document: SceneDocument,
brushIds: string[],
activeSelectionId: string | null
): TransformTargetResolution {
const items: BrushTransformTarget[] = [];
for (const brushId of brushIds) {
const itemResolution = createBrushTransformTarget(document, brushId);
if (itemResolution.target === null || itemResolution.target.kind !== "brush") {
return itemResolution;
}
items.push(itemResolution.target);
}
const activeBrushId = resolveActiveTransformTargetId(
brushIds,
activeSelectionId
);
if (activeBrushId === null) {
return {
target: null,
message: "Select at least one whitebox solid before transforming it."
};
}
return {
target: {
kind: "brushes",
activeBrushId,
initialPivot: averageVec3(items.map((item) => item.initialCenter)),
items
},
message: null
};
}
function createBrushFaceTransformTarget(
document: SceneDocument,
brushId: string,
faceId: WhiteboxFaceId
): TransformTargetResolution {
const brushResolution = createBrushTransformTarget(document, brushId);
if (
brushResolution.target === null ||
brushResolution.target.kind !== "brush"
) {
return brushResolution;
}
return {
target: {
kind: "brushFace",
brushId,
brushKind: brushResolution.target.brushKind,
sideCount: brushResolution.target.sideCount,
majorSegmentCount: brushResolution.target.majorSegmentCount,
tubeSegmentCount: brushResolution.target.tubeSegmentCount,
faceId,
initialCenter: cloneVec3(brushResolution.target.initialCenter),
initialRotationDegrees: cloneVec3(
brushResolution.target.initialRotationDegrees
),
initialSize: cloneVec3(brushResolution.target.initialSize),
initialGeometry: cloneBrushGeometry(
brushResolution.target.initialGeometry
)
},
message: null
};
}
function createBrushEdgeTransformTarget(
document: SceneDocument,
brushId: string,
edgeId: WhiteboxEdgeId
): TransformTargetResolution {
const brushResolution = createBrushTransformTarget(document, brushId);
if (
brushResolution.target === null ||
brushResolution.target.kind !== "brush"
) {
return brushResolution;
}
return {
target: {
kind: "brushEdge",
brushId,
brushKind: brushResolution.target.brushKind,
sideCount: brushResolution.target.sideCount,
majorSegmentCount: brushResolution.target.majorSegmentCount,
tubeSegmentCount: brushResolution.target.tubeSegmentCount,
edgeId,
initialCenter: cloneVec3(brushResolution.target.initialCenter),
initialRotationDegrees: cloneVec3(
brushResolution.target.initialRotationDegrees
),
initialSize: cloneVec3(brushResolution.target.initialSize),
initialGeometry: cloneBrushGeometry(
brushResolution.target.initialGeometry
)
},
message: null
};
}
function createBrushVertexTransformTarget(
document: SceneDocument,
brushId: string,
vertexId: WhiteboxVertexId
): TransformTargetResolution {
const brushResolution = createBrushTransformTarget(document, brushId);
if (
brushResolution.target === null ||
brushResolution.target.kind !== "brush"
) {
return brushResolution;
}
return {
target: {
kind: "brushVertex",
brushId,
brushKind: brushResolution.target.brushKind,
sideCount: brushResolution.target.sideCount,
majorSegmentCount: brushResolution.target.majorSegmentCount,
tubeSegmentCount: brushResolution.target.tubeSegmentCount,
vertexId,
initialCenter: cloneVec3(brushResolution.target.initialCenter),
initialRotationDegrees: cloneVec3(
brushResolution.target.initialRotationDegrees
),
initialSize: cloneVec3(brushResolution.target.initialSize),
initialGeometry: cloneBrushGeometry(
brushResolution.target.initialGeometry
)
},
message: null
};
}
function createEntityTransformTarget(
document: SceneDocument,
entityId: string
): TransformTargetResolution {
const entity = document.entities[entityId];
if (entity === undefined) {
return {
target: null,
message: "Select an authored entity before transforming it."
};
}
if (entity.kind === "cameraRig" && entity.rigType === "rail") {
return {
target: null,
message: "Rail camera rigs are positioned by their assigned path."
};
}
if (!("position" in entity)) {
return {
target: null,
message: "Select an authored entity before transforming it."
};
}
const clonedEntity = cloneEntityInstance(entity);
return {
target: {
kind: "entity",
entityId: clonedEntity.id,
entityKind: clonedEntity.kind,
initialPosition: cloneVec3(entity.position),
initialRotation: resolveEntityRotation(clonedEntity)
},
message: null
};
}
function createEntitiesTransformTarget(
document: SceneDocument,
entityIds: string[],
activeSelectionId: string | null
): TransformTargetResolution {
const items: EntityTransformTarget[] = [];
for (const entityId of entityIds) {
const itemResolution = createEntityTransformTarget(document, entityId);
if (itemResolution.target === null || itemResolution.target.kind !== "entity") {
return itemResolution;
}
items.push(itemResolution.target);
}
const activeEntityId = resolveActiveTransformTargetId(
entityIds,
activeSelectionId
);
if (activeEntityId === null) {
return {
target: null,
message: "Select at least one authored entity before transforming it."
};
}
return {
target: {
kind: "entities",
activeEntityId,
initialPivot: averageVec3(items.map((item) => item.initialPosition)),
items
},
message: null
};
}
function createModelInstanceTransformTarget(
document: SceneDocument,
modelInstanceId: string
): TransformTargetResolution {
const modelInstance = document.modelInstances[modelInstanceId];
if (modelInstance === undefined) {
return {
target: null,
message: "Select a model instance before transforming it."
};
}
const clonedModelInstance = cloneModelInstance(modelInstance);
return {
target: {
kind: "modelInstance",
modelInstanceId: clonedModelInstance.id,
assetId: clonedModelInstance.assetId,
initialPosition: cloneVec3(clonedModelInstance.position),
initialRotationDegrees: cloneVec3(clonedModelInstance.rotationDegrees),
initialScale: cloneVec3(clonedModelInstance.scale)
},
message: null
};
}
function createModelInstancesTransformTarget(
document: SceneDocument,
modelInstanceIds: string[],
activeSelectionId: string | null
): TransformTargetResolution {
const items: ModelInstanceTransformTarget[] = [];
for (const modelInstanceId of modelInstanceIds) {
const itemResolution = createModelInstanceTransformTarget(
document,
modelInstanceId
);
if (
itemResolution.target === null ||
itemResolution.target.kind !== "modelInstance"
) {
return itemResolution;
}
items.push(itemResolution.target);
}
const activeModelInstanceId = resolveActiveTransformTargetId(
modelInstanceIds,
activeSelectionId
);
if (activeModelInstanceId === null) {
return {
target: null,
message: "Select at least one model instance before transforming it."
};
}
return {
target: {
kind: "modelInstances",
activeModelInstanceId,
initialPivot: averageVec3(items.map((item) => item.initialPosition)),
items
},
message: null
};
}
function createPathPointTransformTarget(
document: SceneDocument,
pathId: string,
pointId: string
): TransformTargetResolution {
const path = document.paths[pathId];
if (path === undefined) {
return {
target: null,
message: "Select a path point before transforming it."
};
}
const point = getScenePathPoint(path, pointId);
if (point === null) {
return {
target: null,
message: "Select a valid path point before transforming it."
};
}
return {
target: {
kind: "pathPoint",
pathId,
pointId,
initialPosition: cloneVec3(point.position)
},
message: null
};
}
export function resolveTransformTarget(
document: SceneDocument,
selection: EditorSelection,
whiteboxSelectionMode: WhiteboxSelectionMode = "object",
activeSelectionId: string | null = resolveSelectionActiveId(selection, null)
): TransformTargetResolution {
switch (selection.kind) {
case "none":
return {
target: null,
message:
"Select a single brush, path point, entity, or model instance before transforming it."
};
case "brushFace":
if (whiteboxSelectionMode !== "face") {
return {
target: null,
message: "Switch to Face mode to transform a selected whitebox face."
};
}
return createBrushFaceTransformTarget(
document,
selection.brushId,
selection.faceId
);
case "brushEdge":
if (whiteboxSelectionMode !== "edge") {
return {
target: null,
message: "Switch to Edge mode to transform a selected whitebox edge."
};
}
return createBrushEdgeTransformTarget(
document,
selection.brushId,
selection.edgeId
);
case "brushVertex":
if (whiteboxSelectionMode !== "vertex") {
return {
target: null,
message:
"Switch to Vertex mode to transform a selected whitebox vertex."
};
}
return createBrushVertexTransformTarget(
document,
selection.brushId,
selection.vertexId
);
case "brushes":
if (whiteboxSelectionMode !== "object") {
return {
target: null,
message:
"Switch to Object mode to transform the whole whitebox solid."
};
}
if (selection.ids.length === 0) {
return {
target: null,
message: "Select at least one brush before transforming it."
};
}
return selection.ids.length === 1
? createBrushTransformTarget(document, selection.ids[0])
: createBrushesTransformTarget(document, selection.ids, activeSelectionId);
case "terrains":
return {
target: null,
message:
"Terrain transforms and editing tools land in a later inspector slice."
};
case "entities":
if (selection.ids.length === 0) {
return {
target: null,
message: "Select at least one entity before transforming it."
};
}
return selection.ids.length === 1
? createEntityTransformTarget(document, selection.ids[0])
: createEntitiesTransformTarget(document, selection.ids, activeSelectionId);
case "paths":
return {
target: null,
message:
"Select a path point before transforming a path."
};
case "pathPoint":
return createPathPointTransformTarget(
document,
selection.pathId,
selection.pointId
);
case "modelInstances":
if (selection.ids.length === 0) {
return {
target: null,
message: "Select at least one model instance before transforming it."
};
}
return selection.ids.length === 1
? createModelInstanceTransformTarget(document, selection.ids[0])
: createModelInstancesTransformTarget(
document,
selection.ids,
activeSelectionId
);
}
}