Add Surface Snap Move feature and update related components
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 : (
|
||||
|
||||
@@ -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"
|
||||
|
||||
361
src/viewport-three/transform-surface-snap.ts
Normal file
361
src/viewport-three/transform-surface-snap.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user