Add Surface Snap Move feature and update related components

This commit is contained in:
2026-04-18 19:31:30 +02:00
parent eaa3cfd987
commit ab8cfd9fb3
11 changed files with 1195 additions and 42 deletions

View File

@@ -88,6 +88,7 @@ import {
supportsLocalTransformAxisConstraint,
supportsTransformAxisConstraint,
supportsTransformOperation,
supportsTransformSurfaceSnapTarget,
type ActiveTransformSession,
type TransformAxis,
type TransformOperation,
@@ -2856,6 +2857,13 @@ export function App({ store, initialStatusMessage }: AppProps) {
const canScaleSelectedTarget =
selectedTransformTarget !== null &&
supportsTransformOperation(selectedTransformTarget, "scale");
const surfaceSnapTransformTarget =
transformSession.kind === "active"
? transformSession.target
: selectedTransformTarget;
const canSurfaceSnapTransformTarget =
surfaceSnapTransformTarget !== null &&
supportsTransformSurfaceSnapTarget(surfaceSnapTransformTarget);
const whiteboxSnapStep = editorState.whiteboxSnapStep;
const whiteboxVectorInputStep = getWhiteboxInputStep(
whiteboxSnapEnabled,
@@ -6123,6 +6131,33 @@ export function App({ store, initialStatusMessage }: AppProps) {
);
};
const toggleTransformSurfaceSnap = () => {
if (
transformSession.kind !== "active" ||
transformSession.operation !== "translate"
) {
return;
}
if (!supportsTransformSurfaceSnapTarget(transformSession.target)) {
setStatusMessage(
`Surface Snap Move is not available for ${getTransformTargetLabel(transformSession.target).toLowerCase()}.`
);
return;
}
const nextSurfaceSnapEnabled = !transformSession.surfaceSnapEnabled;
store.setTransformSession({
...transformSession,
surfaceSnapEnabled: nextSurfaceSnapEnabled
});
setStatusMessage(
nextSurfaceSnapEnabled
? "Enabled Surface Snap Move."
: "Disabled Surface Snap Move."
);
};
const handleViewportQuadResizeStart =
(resizeMode: ViewportQuadResizeMode) =>
(event: ReactPointerEvent<HTMLDivElement>) => {
@@ -12172,6 +12207,9 @@ export function App({ store, initialStatusMessage }: AppProps) {
canTranslateSelectedTarget={canTranslateSelectedTarget}
canRotateSelectedTarget={canRotateSelectedTarget}
canScaleSelectedTarget={canScaleSelectedTarget}
canSurfaceSnapTransformTarget={
canSurfaceSnapTransformTarget
}
cameraState={editorState.viewportPanels[panelId].cameraState}
focusRequestId={
focusRequest.panelId === panelId ? focusRequest.id : 0
@@ -12193,6 +12231,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
onBeginTransformOperation={(operation) =>
beginTransformOperation(operation, "toolbar")
}
onToggleTransformSurfaceSnap={toggleTransformSurfaceSnap}
onWhiteboxSelectionModeChange={
handleWhiteboxSelectionModeChange
}

View File

@@ -195,6 +195,7 @@ export function createModelInstanceRenderGroup(
const previewShell = createWireframeBox(bounds.size, previewShellColor, MODEL_PREVIEW_SHELL_OPACITY);
previewShell.position.set(bounds.center.x, bounds.center.y, bounds.center.z);
previewShell.userData.shadowIgnored = true;
previewShell.userData.nonPickable = true;
group.add(previewShell);
}
@@ -202,6 +203,7 @@ export function createModelInstanceRenderGroup(
const selectionShell = createWireframeBox(bounds.size, MODEL_SELECTION_COLOR, 0.8);
selectionShell.position.set(bounds.center.x, bounds.center.y, bounds.center.z);
selectionShell.userData.shadowIgnored = true;
selectionShell.userData.nonPickable = true;
group.add(selectionShell);
}

View File

@@ -283,6 +283,7 @@ export interface ActiveTransformSession {
source: TransformSessionSource;
sourcePanelId: ViewportPanelId;
operation: TransformOperation;
surfaceSnapEnabled: boolean;
axisConstraint: TransformAxis | null;
axisConstraintSpace: TransformAxisSpace;
target: TransformTarget;
@@ -686,6 +687,7 @@ export function cloneTransformSession(
source: session.source,
sourcePanelId: session.sourcePanelId,
operation: session.operation,
surfaceSnapEnabled: session.surfaceSnapEnabled,
axisConstraint: session.axisConstraint,
axisConstraintSpace: session.axisConstraintSpace,
target: cloneTransformTarget(session.target),
@@ -710,6 +712,7 @@ export function areTransformSessionsEqual(
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) &&
@@ -925,6 +928,7 @@ export function createTransformSession(options: {
source: TransformSessionSource;
sourcePanelId: ViewportPanelId;
operation: TransformOperation;
surfaceSnapEnabled?: boolean;
axisConstraint?: TransformAxis | null;
axisConstraintSpace?: TransformAxisSpace;
target: TransformTarget;
@@ -935,6 +939,7 @@ export function createTransformSession(options: {
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),
@@ -1219,6 +1224,27 @@ export function supportsTransformOperation(
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

View File

@@ -34,7 +34,7 @@ function cloneVec3(vector: Vec3): Vec3 {
};
}
function createBrushRotationEuler(brush: Brush): Euler {
function createBrushRotationEuler(brush: { rotationDegrees: Vec3 }): Euler {
return new Euler(
MathUtils.degToRad(brush.rotationDegrees.x),
MathUtils.degToRad(brush.rotationDegrees.y),
@@ -57,7 +57,7 @@ export function getBrushLocalVertexPosition(
}
export function transformBrushWorldVectorToLocal(
brush: Brush,
brush: { rotationDegrees: Vec3 },
worldVector: Vec3
): Vec3 {
const rotation = createBrushRotationEuler(brush);
@@ -76,7 +76,7 @@ export function transformBrushWorldVectorToLocal(
}
export function transformBrushWorldPointToLocal(
brush: Brush,
brush: { center: Vec3; rotationDegrees: Vec3 },
worldPoint: Vec3
): Vec3 {
const rotation = createBrushRotationEuler(brush);
@@ -95,7 +95,7 @@ export function transformBrushWorldPointToLocal(
}
export function transformBrushLocalPointToWorld(
brush: Brush,
brush: { center: Vec3; rotationDegrees: Vec3 },
localPoint: Vec3
): Vec3 {
const rotation = createBrushRotationEuler(brush);
@@ -113,7 +113,7 @@ export function transformBrushLocalPointToWorld(
}
export function getBrushVertexWorldPosition(
brush: Brush,
brush: { center: Vec3; rotationDegrees: Vec3; geometry: BrushGeometry },
vertexId: WhiteboxVertexId
): Vec3 {
return transformBrushLocalPointToWorld(

View File

@@ -337,13 +337,20 @@ export function ViewportCanvas({
>
{transformSession.kind !== "active"
? null
: `${transformSession.operation}${
: [
transformSession.operation,
transformSession.operation === "translate" &&
transformSession.surfaceSnapEnabled
? "surface snap"
: null,
transformSession.axisConstraint === null
? ""
: ` · ${getTransformAxisSpaceLabel(transformSession.axisConstraintSpace)} ${getTransformAxisLabel(
? null
: `${getTransformAxisSpaceLabel(transformSession.axisConstraintSpace)} ${getTransformAxisLabel(
transformSession.axisConstraint
)}`
}`}
]
.filter((part) => part !== null)
.join(" · ")}
</div>
)}
{selectedWhiteboxLabel === null ? null : (

View File

@@ -68,6 +68,7 @@ interface ViewportPanelProps {
canTranslateSelectedTarget: boolean;
canRotateSelectedTarget: boolean;
canScaleSelectedTarget: boolean;
canSurfaceSnapTransformTarget: boolean;
cameraState: ViewportPanelCameraState;
focusRequestId: number;
focusSelection: EditorSelection;
@@ -87,6 +88,7 @@ interface ViewportPanelProps {
onCameraStateChange(cameraState: ViewportPanelCameraState): void;
onToolPreviewChange(toolPreview: ViewportToolPreview): void;
onBeginTransformOperation(operation: TransformOperation): void;
onToggleTransformSurfaceSnap(): void;
onWhiteboxSelectionModeChange(mode: WhiteboxSelectionMode): void;
onViewportGridToggle(): void;
onWhiteboxSnapToggle(): void;
@@ -140,6 +142,7 @@ export function ViewportPanel({
canTranslateSelectedTarget,
canRotateSelectedTarget,
canScaleSelectedTarget,
canSurfaceSnapTransformTarget,
cameraState,
focusRequestId,
focusSelection,
@@ -153,6 +156,7 @@ export function ViewportPanel({
onCameraStateChange,
onToolPreviewChange,
onBeginTransformOperation,
onToggleTransformSurfaceSnap,
onWhiteboxSelectionModeChange,
onViewportGridToggle,
onWhiteboxSnapToggle,
@@ -375,6 +379,28 @@ export function ViewportPanel({
>
Move
</button>
{transformSession.kind === "active" &&
transformSession.operation === "translate" ? (
<button
className={`viewport-panel__button ${transformSession.surfaceSnapEnabled ? "viewport-panel__button--active" : ""}`}
type="button"
data-testid={getSharedControlTestId(
panelId,
isActive,
"transform-surface-snap-button"
)}
aria-pressed={transformSession.surfaceSnapEnabled}
disabled={!canSurfaceSnapTransformTarget}
onClick={onToggleTransformSurfaceSnap}
title={
canSurfaceSnapTransformTarget
? "Snap the current move preview onto the visible surface under the cursor."
: "Surface Snap Move currently supports whitebox solids, model instances, and trigger volumes."
}
>
Surface Snap
</button>
) : null}
<button
className={`viewport-panel__button ${transformSession.kind === "active" && transformSession.operation === "rotate" ? "viewport-panel__button--active" : ""}`}
type="button"

View File

@@ -0,0 +1,361 @@
import {
Euler,
Matrix3,
Quaternion,
Vector3,
type Object3D
} from "three";
import type { ProjectAssetBoundingBox } from "../assets/project-assets";
import type { TransformPreview } from "../core/transform-session";
import type { Vec3 } from "../core/vector";
import type { BrushGeometry } from "../document/brushes";
import { transformBrushLocalPointToWorld } from "../geometry/whitebox-brush";
export const SURFACE_SNAP_OFFSET = 0.01;
export interface SurfaceSnapHit {
object: Object3D;
point: Vec3;
normal: Vec3;
}
export interface SurfaceSnapIntersectionLike {
object: Object3D;
point: {
x: number;
y: number;
z: number;
};
face?:
| {
normal?: {
x: number;
y: number;
z: number;
};
}
| null
| undefined;
}
function cloneVec3(vector: Vec3): Vec3 {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function addVec3(left: Vec3, right: Vec3): Vec3 {
return {
x: left.x + right.x,
y: left.y + right.y,
z: left.z + right.z
};
}
function subtractVec3(left: Vec3, right: Vec3): Vec3 {
return {
x: left.x - right.x,
y: left.y - right.y,
z: left.z - right.z
};
}
function dotVec3(left: Vec3, right: Vec3): number {
return left.x * right.x + left.y * right.y + left.z * right.z;
}
function scaleVec3(vector: Vec3, scalar: number): Vec3 {
return {
x: vector.x * scalar,
y: vector.y * scalar,
z: vector.z * scalar
};
}
function normalizeVec3(vector: Vec3): Vec3 {
const length = Math.sqrt(dotVec3(vector, vector));
if (length <= 1e-8) {
return {
x: 0,
y: 0,
z: 0
};
}
return {
x: vector.x / length,
y: vector.y / length,
z: vector.z / length
};
}
function toVector3(vector: Vec3): Vector3 {
return new Vector3(vector.x, vector.y, vector.z);
}
function fromVector3(vector: { x: number; y: number; z: number }): Vec3 {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function getDefaultModelBoundingBox(): ProjectAssetBoundingBox {
return {
min: { x: -0.5, y: -0.5, z: -0.5 },
max: { x: 0.5, y: 0.5, z: 0.5 },
size: { x: 1, y: 1, z: 1 }
};
}
export function createBrushSurfaceSnapSupportPoints(brush: {
center: Vec3;
rotationDegrees: Vec3;
geometry: BrushGeometry;
}): Vec3[] {
return Object.values(brush.geometry.vertices).map((vertex) =>
transformBrushLocalPointToWorld(brush, vertex)
);
}
export function createAxisAlignedBoxSurfaceSnapSupportPoints(
center: Vec3,
size: Vec3
): Vec3[] {
const halfSize = {
x: size.x * 0.5,
y: size.y * 0.5,
z: size.z * 0.5
};
return [
{ x: -halfSize.x, y: -halfSize.y, z: -halfSize.z },
{ x: halfSize.x, y: -halfSize.y, z: -halfSize.z },
{ x: -halfSize.x, y: halfSize.y, z: -halfSize.z },
{ x: halfSize.x, y: halfSize.y, z: -halfSize.z },
{ x: -halfSize.x, y: -halfSize.y, z: halfSize.z },
{ x: halfSize.x, y: -halfSize.y, z: halfSize.z },
{ x: -halfSize.x, y: halfSize.y, z: halfSize.z },
{ x: halfSize.x, y: halfSize.y, z: halfSize.z }
].map((offset) => addVec3(center, offset));
}
export function createModelBoundingBoxSurfaceSnapSupportPoints(options: {
position: Vec3;
rotationDegrees: Vec3;
scale: Vec3;
boundingBox: ProjectAssetBoundingBox | null;
}): Vec3[] {
const boundingBox = options.boundingBox ?? getDefaultModelBoundingBox();
const rotation = new Quaternion().setFromEuler(
new Euler(
(options.rotationDegrees.x * Math.PI) / 180,
(options.rotationDegrees.y * Math.PI) / 180,
(options.rotationDegrees.z * Math.PI) / 180,
"XYZ"
)
);
return [
{ x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.min.z },
{ x: boundingBox.max.x, y: boundingBox.min.y, z: boundingBox.min.z },
{ x: boundingBox.min.x, y: boundingBox.max.y, z: boundingBox.min.z },
{ x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.min.z },
{ x: boundingBox.min.x, y: boundingBox.min.y, z: boundingBox.max.z },
{ x: boundingBox.max.x, y: boundingBox.min.y, z: boundingBox.max.z },
{ x: boundingBox.min.x, y: boundingBox.max.y, z: boundingBox.max.z },
{ x: boundingBox.max.x, y: boundingBox.max.y, z: boundingBox.max.z }
].map((corner) => {
const rotatedCorner = new Vector3(
corner.x * options.scale.x,
corner.y * options.scale.y,
corner.z * options.scale.z
).applyQuaternion(rotation);
return {
x: options.position.x + rotatedCorner.x,
y: options.position.y + rotatedCorner.y,
z: options.position.z + rotatedCorner.z
};
});
}
export function findSurfaceSnapSupportPoint(
candidatePoints: readonly Vec3[],
normal: Vec3
): Vec3 | null {
if (candidatePoints.length === 0) {
return null;
}
let supportPoint = candidatePoints[0];
let supportDot = dotVec3(supportPoint, normal);
for (const candidatePoint of candidatePoints.slice(1)) {
const candidateDot = dotVec3(candidatePoint, normal);
if (candidateDot < supportDot) {
supportPoint = candidatePoint;
supportDot = candidateDot;
}
}
return cloneVec3(supportPoint);
}
export function projectOntoAxis(vector: Vec3, axis: Vec3): Vec3 {
const normalizedAxis = normalizeVec3(axis);
if (dotVec3(normalizedAxis, normalizedAxis) <= 1e-8) {
return {
x: 0,
y: 0,
z: 0
};
}
return scaleVec3(normalizedAxis, dotVec3(vector, normalizedAxis));
}
export function computeSurfaceSnapDelta(options: {
supportPoints: readonly Vec3[];
hit: SurfaceSnapHit | null;
axisVector?: Vec3 | null;
surfaceOffset?: number;
}): Vec3 | null {
if (options.hit === null) {
return null;
}
const supportPoint = findSurfaceSnapSupportPoint(
options.supportPoints,
options.hit.normal
);
if (supportPoint === null) {
return null;
}
const desiredPoint = addVec3(
options.hit.point,
scaleVec3(options.hit.normal, options.surfaceOffset ?? SURFACE_SNAP_OFFSET)
);
const delta = subtractVec3(desiredPoint, supportPoint);
return options.axisVector === null || options.axisVector === undefined
? delta
: projectOntoAxis(delta, options.axisVector);
}
export function applyRigidDeltaToTransformPreview(
preview: TransformPreview,
delta: Vec3
): TransformPreview {
switch (preview.kind) {
case "brush":
return {
...preview,
center: addVec3(preview.center, delta)
};
case "brushes":
return {
...preview,
pivot: addVec3(preview.pivot, delta),
items: preview.items.map((item) => ({
...item,
center: addVec3(item.center, delta)
}))
};
case "modelInstance":
return {
...preview,
position: addVec3(preview.position, delta)
};
case "modelInstances":
return {
...preview,
pivot: addVec3(preview.pivot, delta),
items: preview.items.map((item) => ({
...item,
position: addVec3(item.position, delta)
}))
};
case "entity":
return {
...preview,
position: addVec3(preview.position, delta)
};
case "entities":
return {
...preview,
pivot: addVec3(preview.pivot, delta),
items: preview.items.map((item) => ({
...item,
position: addVec3(item.position, delta)
}))
};
case "pathPoint":
return {
...preview,
position: addVec3(preview.position, delta)
};
}
}
export function getSurfaceSnapHitWorldNormal(
hit: SurfaceSnapIntersectionLike,
rayDirection: Vec3
): Vec3 | null {
const localNormal = hit.face?.normal;
if (localNormal === undefined || localNormal === null) {
return null;
}
const worldNormal = toVector3(fromVector3(localNormal))
.applyMatrix3(new Matrix3().getNormalMatrix(hit.object.matrixWorld))
.normalize();
if (worldNormal.lengthSq() <= 1e-8) {
return null;
}
return dotVec3(fromVector3(worldNormal), normalizeVec3(rayDirection)) < 0
? fromVector3(worldNormal)
: null;
}
export function resolveSurfaceSnapHitFromIntersections(options: {
hits: readonly SurfaceSnapIntersectionLike[];
rayDirection: Vec3;
isObjectExcluded?(object: Object3D): boolean;
}): SurfaceSnapHit | null {
for (const hit of options.hits) {
if (hit.object.userData.nonPickable === true) {
continue;
}
if (options.isObjectExcluded?.(hit.object) === true) {
continue;
}
const normal = getSurfaceSnapHitWorldNormal(hit, options.rayDirection);
if (normal === null) {
continue;
}
return {
object: hit.object,
point: cloneVec3(fromVector3(hit.point)),
normal
};
}
return null;
}

View File

@@ -64,10 +64,12 @@ import {
resolveTransformTarget,
supportsLocalTransformAxisConstraint,
supportsTransformOperation,
supportsTransformSurfaceSnapTarget,
supportsTransformAxisConstraint,
type ActiveTransformSession,
type TransformAxis,
type TransformAxisSpace,
type TransformPreview,
type TransformSessionState
} from "../core/transform-session";
import type { ToolMode } from "../core/tool-mode";
@@ -204,6 +206,15 @@ import {
import type { RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build";
import { resolveTransformPointerDownIntent } from "./transform-pointer-intent";
import { resolveDominantLocalAxisForWorldAxis } from "./transform-axis-mapping";
import {
SURFACE_SNAP_OFFSET,
applyRigidDeltaToTransformPreview,
computeSurfaceSnapDelta,
createAxisAlignedBoxSurfaceSnapSupportPoints,
createBrushSurfaceSnapSupportPoints,
createModelBoundingBoxSurfaceSnapSupportPoints,
resolveSurfaceSnapHitFromIntersections
} from "./transform-surface-snap";
import {
getViewportViewModeDefinition,
isOrthographicViewportViewMode,
@@ -832,6 +843,7 @@ export class ViewportHost {
setTransformSession(transformSession: TransformSessionState) {
const previousTransformSession = this.currentTransformSession;
let rebuiltPreviewFromPointer = false;
this.currentTransformSession = cloneTransformSession(transformSession);
if (this.currentTransformSession.kind === "none") {
@@ -856,8 +868,10 @@ export class ViewportHost {
previousTransformSession.kind === "active" &&
this.currentTransformSession.kind === "active" &&
previousTransformSession.id === this.currentTransformSession.id &&
(previousTransformSession.axisConstraint !==
this.currentTransformSession.axisConstraint ||
(previousTransformSession.surfaceSnapEnabled !==
this.currentTransformSession.surfaceSnapEnabled ||
previousTransformSession.axisConstraint !==
this.currentTransformSession.axisConstraint ||
previousTransformSession.axisConstraintSpace !==
this.currentTransformSession.axisConstraintSpace) &&
this.currentTransformSession.sourcePanelId === this.panelId &&
@@ -877,10 +891,15 @@ export class ViewportHost {
this.currentTransformSession.axisConstraint,
this.currentTransformSession.axisConstraintSpace
);
rebuiltPreviewFromPointer = true;
}
this.applyTransformPreview();
this.syncTransformGizmo();
if (rebuiltPreviewFromPointer) {
this.transformSessionChangeHandler?.(this.currentTransformSession);
}
}
setToolMode(toolMode: ToolMode) {
@@ -2081,6 +2100,7 @@ export class ViewportHost {
source: "gizmo",
sourcePanelId: this.panelId,
operation: "translate",
surfaceSnapEnabled: false,
axisConstraint: null,
axisConstraintSpace: "world",
target: transformTarget,
@@ -2368,13 +2388,21 @@ export class ViewportHost {
session.target.kind === "entities" ||
session.target.kind === "modelInstances"
) {
return this.buildBatchTranslatedPreview(
const preview = this.buildBatchTranslatedPreview(
session,
origin,
current,
axisConstraint,
axisConstraintSpace
);
return this.applySurfaceSnapMoveToTranslatedPreview(
session,
preview,
current,
axisConstraint,
axisConstraintSpace
);
}
if (
@@ -2485,8 +2513,10 @@ export class ViewportHost {
);
}
let preview: TransformPreview;
if (session.target.kind === "brush") {
return {
preview = {
kind: "brush" as const,
center: nextPosition,
rotationDegrees: {
@@ -2497,10 +2527,8 @@ export class ViewportHost {
},
geometry: cloneBrushGeometry(session.target.initialGeometry)
};
}
if (session.target.kind === "modelInstance") {
return {
} else if (session.target.kind === "modelInstance") {
preview = {
kind: "modelInstance" as const,
position: nextPosition,
rotationDegrees: {
@@ -2510,35 +2538,41 @@ export class ViewportHost {
...session.target.initialScale
}
};
}
if (session.target.kind === "pathPoint") {
return {
} else if (session.target.kind === "pathPoint") {
preview = {
kind: "pathPoint" as const,
position: nextPosition
};
} else {
preview = {
kind: "entity" as const,
position: nextPosition,
rotation:
session.target.initialRotation.kind === "yaw"
? {
kind: "yaw" as const,
yawDegrees: session.target.initialRotation.yawDegrees
}
: session.target.initialRotation.kind === "direction"
? {
kind: "direction" as const,
direction: {
...session.target.initialRotation.direction
}
}
: {
kind: "none" as const
}
};
}
return {
kind: "entity" as const,
position: nextPosition,
rotation:
session.target.initialRotation.kind === "yaw"
? {
kind: "yaw" as const,
yawDegrees: session.target.initialRotation.yawDegrees
}
: session.target.initialRotation.kind === "direction"
? {
kind: "direction" as const,
direction: {
...session.target.initialRotation.direction
}
}
: {
kind: "none" as const
}
};
return this.applySurfaceSnapMoveToTranslatedPreview(
session,
preview,
current,
axisConstraint,
axisConstraintSpace
);
}
private buildRotatedPreview(
@@ -3118,6 +3152,319 @@ export class ViewportHost {
};
}
private applySurfaceSnapMoveToTranslatedPreview(
session: ActiveTransformSession,
preview: TransformPreview,
current: { x: number; y: number },
axisConstraint: TransformAxis | null,
axisConstraintSpace: TransformAxisSpace
): TransformPreview {
if (
!session.surfaceSnapEnabled ||
!supportsTransformSurfaceSnapTarget(session.target)
) {
return preview;
}
const hit = this.getSurfaceSnapHitAtPointer(session, current);
if (hit === null) {
return preview;
}
const supportPoints = this.collectSurfaceSnapSupportPoints(session, preview);
const axisVector =
axisConstraint === null
? null
: this.getConstraintAxisWorldVector(
session,
axisConstraint,
axisConstraintSpace
);
const delta = computeSurfaceSnapDelta({
supportPoints,
hit,
axisVector:
axisVector === null
? null
: {
x: axisVector.x,
y: axisVector.y,
z: axisVector.z
},
surfaceOffset: SURFACE_SNAP_OFFSET
});
return delta === null
? preview
: applyRigidDeltaToTransformPreview(preview, delta);
}
private getSurfaceSnapHitAtPointer(
session: ActiveTransformSession,
current: { x: number; y: number }
) {
if (!this.setPointerFromClientPosition(current.x, current.y)) {
return null;
}
const excludedIds = this.getSurfaceSnapExcludedIds(session);
const raycastObjects = this.getSurfaceSnapRaycastObjects(excludedIds);
if (raycastObjects.length === 0) {
return null;
}
this.raycaster.setFromCamera(this.pointer, this.getActiveCamera());
return resolveSurfaceSnapHitFromIntersections({
hits: this.raycaster.intersectObjects(raycastObjects, true),
rayDirection: {
x: this.raycaster.ray.direction.x,
y: this.raycaster.ray.direction.y,
z: this.raycaster.ray.direction.z
},
isObjectExcluded: (object) =>
this.isSurfaceSnapObjectExcluded(object, excludedIds)
});
}
private getSurfaceSnapExcludedIds(session: ActiveTransformSession) {
const brushIds = new Set<string>();
const entityIds = new Set<string>();
const modelInstanceIds = new Set<string>();
switch (session.target.kind) {
case "brush":
brushIds.add(session.target.brushId);
break;
case "brushes":
for (const item of session.target.items) {
brushIds.add(item.brushId);
}
break;
case "entity":
entityIds.add(session.target.entityId);
break;
case "entities":
for (const item of session.target.items) {
entityIds.add(item.entityId);
}
break;
case "modelInstance":
modelInstanceIds.add(session.target.modelInstanceId);
break;
case "modelInstances":
for (const item of session.target.items) {
modelInstanceIds.add(item.modelInstanceId);
}
break;
case "brushFace":
case "brushEdge":
case "brushVertex":
case "pathPoint":
break;
}
return {
brushIds,
entityIds,
modelInstanceIds
};
}
private isSurfaceSnapObjectExcluded(
object: Object3D,
excludedIds: {
brushIds: ReadonlySet<string>;
entityIds: ReadonlySet<string>;
modelInstanceIds: ReadonlySet<string>;
}
): boolean {
let current: Object3D | null = object;
while (current !== null) {
const brushId = current.userData.brushId;
if (
typeof brushId === "string" &&
excludedIds.brushIds.has(brushId)
) {
return true;
}
const entityId = current.userData.entityId;
if (
typeof entityId === "string" &&
excludedIds.entityIds.has(entityId)
) {
return true;
}
const modelInstanceId = current.userData.modelInstanceId;
if (
typeof modelInstanceId === "string" &&
excludedIds.modelInstanceIds.has(modelInstanceId)
) {
return true;
}
current = current.parent;
}
return false;
}
private getSurfaceSnapRaycastObjects(excludedIds: {
brushIds: ReadonlySet<string>;
entityIds: ReadonlySet<string>;
modelInstanceIds: ReadonlySet<string>;
}): Object3D[] {
const raycastObjects: Object3D[] = [];
for (const [brushId, renderObjects] of this.brushRenderObjects) {
if (excludedIds.brushIds.has(brushId)) {
continue;
}
raycastObjects.push(renderObjects.mesh);
}
if (this.currentDocument !== null) {
for (const [entityId, renderObjects] of this.entityRenderObjects) {
if (excludedIds.entityIds.has(entityId)) {
continue;
}
const entity = this.currentDocument.entities[entityId];
if (entity?.kind !== "triggerVolume") {
continue;
}
raycastObjects.push(renderObjects.group);
}
}
for (const [modelInstanceId, renderGroup] of this.modelRenderObjects) {
if (excludedIds.modelInstanceIds.has(modelInstanceId)) {
continue;
}
raycastObjects.push(renderGroup);
}
return raycastObjects;
}
private collectSurfaceSnapSupportPoints(
session: ActiveTransformSession,
preview: TransformPreview
): Vec3[] {
switch (session.target.kind) {
case "brush":
return preview.kind === "brush"
? createBrushSurfaceSnapSupportPoints(preview)
: [];
case "brushes":
return preview.kind === "brushes"
? preview.items.flatMap((item) =>
createBrushSurfaceSnapSupportPoints(item)
)
: [];
case "modelInstance":
return preview.kind === "modelInstance"
? createModelBoundingBoxSurfaceSnapSupportPoints({
position: preview.position,
rotationDegrees: preview.rotationDegrees,
scale: preview.scale,
boundingBox: this.getModelAssetBoundingBox(session.target.assetId)
})
: [];
case "modelInstances":
if (preview.kind !== "modelInstances") {
return [];
}
const modelInstancesTarget = session.target;
return preview.items.flatMap((item, index) =>
createModelBoundingBoxSurfaceSnapSupportPoints({
position: item.position,
rotationDegrees: item.rotationDegrees,
scale: item.scale,
boundingBox: this.getModelAssetBoundingBox(
modelInstancesTarget.items[index]?.assetId ?? ""
)
})
);
case "entity": {
if (
preview.kind !== "entity" ||
session.target.entityKind !== "triggerVolume" ||
this.currentDocument === null
) {
return [];
}
const entity = this.currentDocument.entities[session.target.entityId];
return entity?.kind === "triggerVolume"
? createAxisAlignedBoxSurfaceSnapSupportPoints(
preview.position,
entity.size
)
: [];
}
case "entities": {
if (preview.kind !== "entities" || this.currentDocument === null) {
return [];
}
const supportPoints: Vec3[] = [];
for (const [index, item] of session.target.items.entries()) {
if (item.entityKind !== "triggerVolume") {
continue;
}
const previewItem = preview.items[index];
if (previewItem === undefined) {
continue;
}
const entity = this.currentDocument.entities[item.entityId];
if (entity?.kind !== "triggerVolume") {
continue;
}
supportPoints.push(
...createAxisAlignedBoxSurfaceSnapSupportPoints(
previewItem.position,
entity.size
)
);
}
return supportPoints;
}
case "brushFace":
case "brushEdge":
case "brushVertex":
case "pathPoint":
return [];
}
}
private getModelAssetBoundingBox(assetId: string) {
const asset = this.projectAssets[assetId];
return asset?.kind === "model" ? asset.metadata.boundingBox : null;
}
private buildBatchRotatedPreview(
session: ActiveTransformSession,
origin: { x: number; y: number },
@@ -6768,6 +7115,7 @@ export class ViewportHost {
source: "gizmo",
sourcePanelId: this.panelId,
operation: interactionSession.operation,
surfaceSnapEnabled: interactionSession.surfaceSnapEnabled,
axisConstraint: transformHandle.axisConstraint,
axisConstraintSpace:
transformHandle.axisConstraint === null