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

@@ -20,6 +20,7 @@ function createActiveBrushTransformSession(): TransformSessionState {
source: "keyboard",
sourcePanelId: "topLeft",
operation: "scale",
surfaceSnapEnabled: false,
axisConstraint: null,
axisConstraintSpace: "world",
target: {

View File

@@ -0,0 +1,277 @@
import { BoxGeometry, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from "three";
import { describe, expect, it } from "vitest";
import { createBoxBrush } from "../../src/document/brushes";
import {
SURFACE_SNAP_OFFSET,
applyRigidDeltaToTransformPreview,
computeSurfaceSnapDelta,
createBrushSurfaceSnapSupportPoints,
createModelBoundingBoxSurfaceSnapSupportPoints,
findSurfaceSnapSupportPoint,
projectOntoAxis,
resolveSurfaceSnapHitFromIntersections
} from "../../src/viewport-three/transform-surface-snap";
function createFrontFaceHit(meshes: Mesh | Mesh[], excludedMesh?: Mesh) {
const rayOrigin = new Vector3(0, 0, 5);
const rayDirection = new Vector3(0, 0, -1);
const raycaster = new Raycaster(rayOrigin, rayDirection);
const objectList = Array.isArray(meshes) ? meshes : [meshes];
for (const mesh of objectList) {
mesh.updateMatrixWorld(true);
}
return resolveSurfaceSnapHitFromIntersections({
hits: raycaster.intersectObjects(objectList, true),
rayDirection: {
x: rayDirection.x,
y: rayDirection.y,
z: rayDirection.z
},
isObjectExcluded: (object) => object === excludedMesh
});
}
describe("transform-surface-snap", () => {
it("selects the support point with the minimum projection on the hit normal", () => {
expect(
findSurfaceSnapSupportPoint(
[
{ x: 2, y: 0, z: 0 },
{ x: -3, y: 1, z: 0 },
{ x: 0, y: -2, z: 0 }
],
{ x: 1, y: 0, z: 0 }
)
).toEqual({
x: -3,
y: 1,
z: 0
});
});
it("returns no snap delta when there is no valid hit", () => {
expect(
computeSurfaceSnapDelta({
supportPoints: [{ x: 0, y: 0, z: 0 }],
hit: null
})
).toBeNull();
});
it("projects the snap delta onto the constrained axis basis", () => {
const delta = computeSurfaceSnapDelta({
supportPoints: [{ x: 1, y: 1, z: 0 }],
hit: {
object: new Mesh(),
point: { x: 5, y: 4, z: 0 },
normal: { x: 0, y: 1, z: 0 }
},
axisVector: { x: 1, y: 0, z: 0 }
});
expect(delta).toEqual({
x: 4,
y: 0,
z: 0
});
expect(
projectOntoAxis(
{
x: 4,
y: 3 + SURFACE_SNAP_OFFSET,
z: 0
},
{ x: 1, y: 0, z: 0 }
)
).toEqual({
x: 4,
y: 0,
z: 0
});
});
it("applies one rigid delta to a batch preview", () => {
const preview = applyRigidDeltaToTransformPreview(
{
kind: "brushes",
pivot: { x: 1, y: 2, z: 3 },
items: [
{
brushId: "brush-a",
center: { x: 0, y: 0, z: 0 },
rotationDegrees: { x: 0, y: 0, z: 0 },
size: { x: 2, y: 2, z: 2 },
geometry: createBoxBrush().geometry
},
{
brushId: "brush-b",
center: { x: 3, y: 4, z: 5 },
rotationDegrees: { x: 0, y: 0, z: 0 },
size: { x: 2, y: 2, z: 2 },
geometry: createBoxBrush().geometry
}
]
},
{ x: -2, y: 5, z: 1 }
);
expect(preview).toMatchObject({
kind: "brushes",
pivot: { x: -1, y: 7, z: 4 },
items: [
{ brushId: "brush-a", center: { x: -2, y: 5, z: 1 } },
{ brushId: "brush-b", center: { x: 1, y: 9, z: 6 } }
]
});
});
it("resolves the closest valid front-face hit while excluding the moving selection", () => {
const excludedMesh = new Mesh(
new BoxGeometry(1, 1, 1),
new MeshBasicMaterial()
);
const targetMesh = new Mesh(
new BoxGeometry(1, 1, 1),
new MeshBasicMaterial()
);
excludedMesh.position.z = 0;
targetMesh.position.z = -2;
const hit = createFrontFaceHit([excludedMesh, targetMesh], excludedMesh);
expect(hit).not.toBeNull();
expect(hit?.point.z).toBeCloseTo(-1.5, 5);
expect(hit?.normal).toEqual({
x: 0,
y: 0,
z: 1
});
});
it("recomputes the same snapped preview from the same base state without accumulation", () => {
const targetMesh = new Mesh(
new BoxGeometry(1, 1, 1),
new MeshBasicMaterial()
);
const hit = createFrontFaceHit(targetMesh);
const brush = createBoxBrush({
center: { x: 0, y: 0, z: -3 },
size: { x: 2, y: 2, z: 2 }
});
const basePreview = {
kind: "brush" as const,
center: brush.center,
rotationDegrees: brush.rotationDegrees,
size: brush.size,
geometry: brush.geometry
};
const snapPreview = () => {
const delta = computeSurfaceSnapDelta({
supportPoints: createBrushSurfaceSnapSupportPoints(basePreview),
hit
});
return delta === null
? basePreview
: applyRigidDeltaToTransformPreview(basePreview, delta);
};
expect(snapPreview()).toEqual(snapPreview());
});
it("lands a whitebox solid on the visible front face even when it starts behind the target", () => {
const targetMesh = new Mesh(
new BoxGeometry(1, 1, 1),
new MeshBasicMaterial()
);
const hit = createFrontFaceHit(targetMesh);
const brush = createBoxBrush({
center: { x: 0, y: 0, z: -3 },
size: { x: 2, y: 2, z: 2 }
});
const delta = computeSurfaceSnapDelta({
supportPoints: createBrushSurfaceSnapSupportPoints(brush),
hit
});
if (delta === null) {
throw new Error("Expected a valid whitebox surface snap delta.");
}
const snappedPreview = applyRigidDeltaToTransformPreview(
{
kind: "brush",
center: brush.center,
rotationDegrees: brush.rotationDegrees,
size: brush.size,
geometry: brush.geometry
},
delta
);
if (snappedPreview.kind !== "brush" || hit === null) {
throw new Error("Expected a snapped whitebox preview.");
}
const supportPoint = findSurfaceSnapSupportPoint(
createBrushSurfaceSnapSupportPoints(snappedPreview),
hit.normal
);
expect(supportPoint?.z).toBeCloseTo(hit.point.z + SURFACE_SNAP_OFFSET, 5);
expect(snappedPreview.center.z).toBeGreaterThan(0);
});
it("lands a model instance on the visible front face under the cursor", () => {
const targetMesh = new Mesh(
new BoxGeometry(1, 1, 1),
new MeshBasicMaterial()
);
const hit = createFrontFaceHit(targetMesh);
const boundingBox = {
min: { x: -1, y: -0.5, z: -1 },
max: { x: 1, y: 0.5, z: 1 },
size: { x: 2, y: 1, z: 2 }
};
const basePreview = {
kind: "modelInstance" as const,
position: { x: 0, y: 0, z: -4 },
rotationDegrees: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
};
const delta = computeSurfaceSnapDelta({
supportPoints: createModelBoundingBoxSurfaceSnapSupportPoints({
position: basePreview.position,
rotationDegrees: basePreview.rotationDegrees,
scale: basePreview.scale,
boundingBox
}),
hit
});
if (delta === null) {
throw new Error("Expected a valid model-instance surface snap delta.");
}
const snappedPreview = applyRigidDeltaToTransformPreview(basePreview, delta);
if (snappedPreview.kind !== "modelInstance" || hit === null) {
throw new Error("Expected a snapped model-instance preview.");
}
const supportPoint = findSurfaceSnapSupportPoint(
createModelBoundingBoxSurfaceSnapSupportPoints({
position: snappedPreview.position,
rotationDegrees: snappedPreview.rotationDegrees,
scale: snappedPreview.scale,
boundingBox
}),
hit.normal
);
expect(supportPoint?.z).toBeCloseTo(hit.point.z + SURFACE_SNAP_OFFSET, 5);
});
});

View File

@@ -1,11 +1,13 @@
import { render, waitFor } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createTransformSession,
createInactiveTransformSession,
type ActiveTransformSession,
type TransformSessionState
} from "../../src/core/transform-session";
import { createBoxBrush } from "../../src/document/brushes";
import { createEmptySceneDocument } from "../../src/document/scene-document";
import { createRuntimeClockState } from "../../src/runtime-three/runtime-project-time";
import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build";
@@ -286,4 +288,68 @@ describe("ViewportCanvas", () => {
);
});
});
it("shows the surface snap translate overlay when the active transform enables it", () => {
const sceneDocument = createEmptySceneDocument();
const brush = createBoxBrush({
id: "overlay-brush",
center: { x: 0, y: 1, z: 0 }
});
render(
<ViewportCanvas
panelId="topLeft"
world={sceneDocument.world}
sceneDocument={sceneDocument}
editorSimulationScene={null}
editorSimulationClock={null}
projectAssets={sceneDocument.assets}
loadedModelAssets={{}}
loadedImageAssets={{}}
whiteboxSelectionMode="object"
whiteboxSnapEnabled
whiteboxSnapStep={1}
viewportGridVisible={true}
selection={{ kind: "brushes", ids: [brush.id] }}
activeSelectionId={brush.id}
toolMode="select"
toolPreview={{ kind: "none" }}
transformSession={createTransformSession({
source: "keyboard",
sourcePanelId: "topLeft",
operation: "translate",
surfaceSnapEnabled: true,
axisConstraint: "x",
axisConstraintSpace: "local",
target: {
kind: "brush",
brushId: brush.id,
brushKind: brush.kind,
initialCenter: brush.center,
initialRotationDegrees: brush.rotationDegrees,
initialSize: brush.size,
initialGeometry: brush.geometry
}
})}
cameraState={createDefaultViewportPanelCameraState()}
viewMode="perspective"
displayMode="normal"
layoutMode="single"
isActivePanel
focusRequestId={0}
focusSelection={{ kind: "none" }}
onSelectionChange={vi.fn()}
onCommitCreation={vi.fn(() => true)}
onCameraStateChange={vi.fn()}
onToolPreviewChange={vi.fn()}
onTransformSessionChange={vi.fn()}
onTransformCommit={vi.fn()}
onTransformCancel={vi.fn()}
/>
);
expect(screen.getByTestId("viewport-transform-preview-topLeft")).toHaveTextContent(
"translate · surface snap · Local X"
);
});
});