Use furniture Z-up axes in installation demo editing
Map X to width, Y to depth, and Z to height in numeric movement, drag and rotation controls, and feedback. Preserve existing Y-up model poses and saved data, and extract transform-anchor helpers.
This commit is contained in:
parent
61ae9b8444
commit
243cfc9825
@ -9,7 +9,8 @@ describe('explosion movement inputs', () => {
|
||||
expect(onTranslate).not.toHaveBeenCalled()
|
||||
fireEvent.change(screen.getByRole('spinbutton', { name: 'Y 轴位移 mm' }), { target: { value: '-12.5' } })
|
||||
fireEvent.submit(screen.getByRole('form', { name: '精确移动部件' }))
|
||||
expect(onTranslate).toHaveBeenCalledWith([0, -12.5, 200])
|
||||
expect(onTranslate).toHaveBeenCalledWith([0, 200, 12.5])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('已移动:Y -12.5 mm,Z +200 mm')
|
||||
expect(screen.getByRole('spinbutton', { name: 'Z 轴位移 mm' })).toHaveValue(0)
|
||||
expect(screen.getByRole('button', { name: '应用位移' })).toBeDisabled()
|
||||
fireEvent.submit(screen.getByRole('form', { name: '精确移动部件' }))
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { toWorldVector3 } from '@/lib/3d/coordinate-system'
|
||||
import type { InstallationVector3 } from '../types'
|
||||
|
||||
interface InstallationExplosionMoveInputProps {
|
||||
@ -21,7 +22,7 @@ export function InstallationExplosionMoveInput({ disabled, onTranslate }: Instal
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
if (!canApply) return
|
||||
onTranslate(delta)
|
||||
onTranslate(toWorldVector3(delta, 'z-up'))
|
||||
setLastMove(delta.map((value, axis) => value === 0 ? '' : `${['X', 'Y', 'Z'][axis]} ${value > 0 ? '+' : ''}${value} mm`).filter(Boolean).join(','))
|
||||
setDraft(['0', '0', '0'])
|
||||
}}
|
||||
@ -30,7 +31,7 @@ export function InstallationExplosionMoveInput({ disabled, onTranslate }: Instal
|
||||
<div className="flex items-end gap-2">
|
||||
{(['X', 'Y', 'Z'] as const).map((axis, index) => (
|
||||
<label key={axis} className="grid min-w-0 flex-1 gap-1 text-[10px] font-black text-[#5a6062]">
|
||||
{axis}
|
||||
<span>{axis} <span className="font-semibold text-[#8b9092]">{['左右', '前后', '上下'][index]}</span></span>
|
||||
<input
|
||||
aria-label={`${axis} 轴位移 mm`}
|
||||
type="number"
|
||||
@ -52,7 +53,7 @@ export function InstallationExplosionMoveInput({ disabled, onTranslate }: Instal
|
||||
</button>
|
||||
</div>
|
||||
<p role="status" className="text-[10px] font-semibold text-[#8b9092]">
|
||||
{lastMove ? `已移动:${lastMove}` : '正数沿轴正方向移动,负数反向移动;按回车应用。'}
|
||||
{lastMove ? `已移动:${lastMove}` : '正方向:X 向右 · Y 向后 · Z 向上;负数反向,按回车应用。'}
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
|
||||
@ -615,6 +615,7 @@ export function InstallationPreviewStage({
|
||||
preserveDrawingBuffer
|
||||
repairDisplayNormals
|
||||
installationPerformance={installationPerformance}
|
||||
coordinateSystem="z-up"
|
||||
showFpsBadge={false}
|
||||
className="h-full"
|
||||
viewportControlsEnabled={
|
||||
|
||||
@ -1,6 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
configureGizmoCoordinateFrame,
|
||||
fromWorldVector3,
|
||||
getCoordinateWorldAxes,
|
||||
type ViewportCoordinateSystem,
|
||||
} from "@/lib/3d/coordinate-system";
|
||||
import {
|
||||
applyGizmoAnchorTransformToTarget,
|
||||
getObjectWorldBoundsCenter,
|
||||
MODEL_VIEWER_GIZMO_ANCHOR_NAME,
|
||||
syncGizmoAnchorToSelectionCenter,
|
||||
type TransformGizmoTargetState,
|
||||
} from "./model-viewer-transform-anchor";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
@ -104,6 +117,7 @@ interface ModelViewerProps {
|
||||
gizmoMode?: 'translate' | 'rotate';
|
||||
gizmoSize?: number;
|
||||
gizmoAxisColors?: Partial<Record<AxisKey, string>>;
|
||||
coordinateSystem?: ViewportCoordinateSystem;
|
||||
onGizmoInteractionStart?: (nodeName: string) => void;
|
||||
onGizmoInteractionEnd?: (nodeName: string) => void;
|
||||
onNodePositionOffsetChange?: (payload: {
|
||||
@ -146,179 +160,12 @@ interface TransformFeedbackBaseline {
|
||||
worldQuaternion: THREE.Quaternion;
|
||||
}
|
||||
|
||||
interface TransformGizmoTargetState {
|
||||
nodeName: string | null;
|
||||
nodeNames: string[];
|
||||
object: THREE.Object3D;
|
||||
targetObjects: Array<{
|
||||
nodeName: string;
|
||||
object: THREE.Object3D;
|
||||
baselineTargetMatrixWorld: THREE.Matrix4 | null;
|
||||
}>;
|
||||
anchor: THREE.Object3D;
|
||||
isFallbackModelTarget: boolean;
|
||||
baselineAnchorPosition: THREE.Vector3 | null;
|
||||
baselineAnchorQuaternion: THREE.Quaternion | null;
|
||||
baselineTargetMatrixWorld: THREE.Matrix4 | null;
|
||||
}
|
||||
|
||||
const TRANSFORM_FEEDBACK_AXIS_KEYS: AxisKey[] = ["X", "Y", "Z"];
|
||||
const TRANSFORM_FEEDBACK_AXIS_COLORS: Record<AxisKey, string> = {
|
||||
X: "#8f625d",
|
||||
Y: "#5f766e",
|
||||
Z: "#5f6d82",
|
||||
};
|
||||
const TRANSFORM_FEEDBACK_WORLD_AXIS: Record<AxisKey, THREE.Vector3> = {
|
||||
X: new THREE.Vector3(1, 0, 0),
|
||||
Y: new THREE.Vector3(0, 1, 0),
|
||||
Z: new THREE.Vector3(0, 0, 1),
|
||||
};
|
||||
const MODEL_VIEWER_GIZMO_ANCHOR_NAME = "__model_viewer_gizmo_anchor__";
|
||||
|
||||
function getObjectWorldBoundsCenter(object: THREE.Object3D): THREE.Vector3 {
|
||||
object.updateMatrixWorld(true);
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
if (!box.isEmpty()) {
|
||||
return box.getCenter(new THREE.Vector3());
|
||||
}
|
||||
return object.getWorldPosition(new THREE.Vector3());
|
||||
}
|
||||
|
||||
function getObjectsWorldBoundsCenter(objects: THREE.Object3D[]): THREE.Vector3 {
|
||||
if (objects.length === 0) {
|
||||
return new THREE.Vector3();
|
||||
}
|
||||
|
||||
const box = new THREE.Box3();
|
||||
let hasBounds = false;
|
||||
objects.forEach((object) => {
|
||||
object.updateMatrixWorld(true);
|
||||
const objectBox = new THREE.Box3().setFromObject(object);
|
||||
if (objectBox.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!hasBounds) {
|
||||
box.copy(objectBox);
|
||||
hasBounds = true;
|
||||
return;
|
||||
}
|
||||
box.union(objectBox);
|
||||
});
|
||||
|
||||
if (hasBounds) {
|
||||
return box.getCenter(new THREE.Vector3());
|
||||
}
|
||||
|
||||
return objects[0]?.getWorldPosition(new THREE.Vector3()) ?? new THREE.Vector3();
|
||||
}
|
||||
|
||||
function syncGizmoAnchorToObjectCenter(
|
||||
anchor: THREE.Object3D,
|
||||
object: THREE.Object3D,
|
||||
nodeName: string | null,
|
||||
) {
|
||||
anchor.name = nodeName
|
||||
? `${nodeName}${MODEL_VIEWER_GIZMO_ANCHOR_NAME}`
|
||||
: MODEL_VIEWER_GIZMO_ANCHOR_NAME;
|
||||
anchor.position.copy(getObjectWorldBoundsCenter(object));
|
||||
anchor.quaternion.copy(object.getWorldQuaternion(new THREE.Quaternion()));
|
||||
anchor.scale.set(1, 1, 1);
|
||||
anchor.updateMatrixWorld(true);
|
||||
}
|
||||
|
||||
function syncGizmoAnchorToSelectionCenter(
|
||||
anchor: THREE.Object3D,
|
||||
objects: THREE.Object3D[],
|
||||
nodeName: string | null,
|
||||
) {
|
||||
const primaryObject = objects[0];
|
||||
if (!primaryObject) {
|
||||
syncGizmoAnchorToObjectCenter(anchor, new THREE.Object3D(), nodeName);
|
||||
return;
|
||||
}
|
||||
|
||||
anchor.name = nodeName
|
||||
? `${nodeName}${MODEL_VIEWER_GIZMO_ANCHOR_NAME}`
|
||||
: MODEL_VIEWER_GIZMO_ANCHOR_NAME;
|
||||
anchor.position.copy(getObjectsWorldBoundsCenter(objects));
|
||||
anchor.quaternion.copy(primaryObject.getWorldQuaternion(new THREE.Quaternion()));
|
||||
anchor.scale.set(1, 1, 1);
|
||||
anchor.updateMatrixWorld(true);
|
||||
}
|
||||
|
||||
function applyGizmoAnchorTransformToTarget(
|
||||
targetState: TransformGizmoTargetState,
|
||||
mode: "translate" | "rotate",
|
||||
) {
|
||||
const {
|
||||
anchor,
|
||||
baselineAnchorPosition,
|
||||
baselineAnchorQuaternion,
|
||||
} = targetState;
|
||||
if (
|
||||
!baselineAnchorPosition ||
|
||||
!baselineAnchorQuaternion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
anchor.updateMatrixWorld(true);
|
||||
const targetObjects =
|
||||
targetState.targetObjects.length > 0
|
||||
? targetState.targetObjects
|
||||
: targetState.nodeName && targetState.baselineTargetMatrixWorld
|
||||
? [
|
||||
{
|
||||
nodeName: targetState.nodeName,
|
||||
object: targetState.object,
|
||||
baselineTargetMatrixWorld: targetState.baselineTargetMatrixWorld,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
targetObjects.forEach((targetObjectState) => {
|
||||
const { object, baselineTargetMatrixWorld } = targetObjectState;
|
||||
if (!baselineTargetMatrixWorld) {
|
||||
return;
|
||||
}
|
||||
|
||||
object.parent?.updateMatrixWorld(true);
|
||||
const nextWorldMatrix = new THREE.Matrix4();
|
||||
|
||||
if (mode === "rotate") {
|
||||
const currentAnchorQuaternion = anchor.getWorldQuaternion(
|
||||
new THREE.Quaternion(),
|
||||
);
|
||||
const deltaQuaternion = currentAnchorQuaternion
|
||||
.clone()
|
||||
.multiply(baselineAnchorQuaternion.clone().invert())
|
||||
.normalize();
|
||||
const pivot = baselineAnchorPosition.clone();
|
||||
const rotateAroundPivotMatrix = new THREE.Matrix4()
|
||||
.makeTranslation(pivot.x, pivot.y, pivot.z)
|
||||
.multiply(new THREE.Matrix4().makeRotationFromQuaternion(deltaQuaternion))
|
||||
.multiply(new THREE.Matrix4().makeTranslation(-pivot.x, -pivot.y, -pivot.z));
|
||||
nextWorldMatrix
|
||||
.copy(rotateAroundPivotMatrix)
|
||||
.multiply(baselineTargetMatrixWorld);
|
||||
} else {
|
||||
const currentAnchorPosition = anchor.getWorldPosition(new THREE.Vector3());
|
||||
const deltaPosition = currentAnchorPosition.sub(baselineAnchorPosition);
|
||||
nextWorldMatrix
|
||||
.makeTranslation(deltaPosition.x, deltaPosition.y, deltaPosition.z)
|
||||
.multiply(baselineTargetMatrixWorld);
|
||||
}
|
||||
|
||||
const parentWorldInverse = object.parent
|
||||
? object.parent.matrixWorld.clone().invert()
|
||||
: new THREE.Matrix4();
|
||||
const nextLocalMatrix = parentWorldInverse.multiply(nextWorldMatrix);
|
||||
nextLocalMatrix.decompose(object.position, object.quaternion, object.scale);
|
||||
object.updateMatrix();
|
||||
object.updateMatrixWorld(true);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAngleRadians(value: number): number {
|
||||
let normalized = value;
|
||||
while (normalized <= -Math.PI) {
|
||||
@ -751,6 +598,7 @@ function ModelViewerComponent({
|
||||
gizmoMode = 'translate',
|
||||
gizmoSize = 0.72,
|
||||
gizmoAxisColors,
|
||||
coordinateSystem = "y-up",
|
||||
onGizmoInteractionStart,
|
||||
onGizmoInteractionEnd,
|
||||
onNodePositionOffsetChange,
|
||||
@ -787,6 +635,7 @@ function ModelViewerComponent({
|
||||
);
|
||||
const enableTranslateGizmoRef = useRef(enableTranslateGizmo);
|
||||
const gizmoModeRef = useRef(gizmoMode);
|
||||
const coordinateSystemRef = useRef(coordinateSystem);
|
||||
const gizmoSizeRef = useRef(resolveGizmoSize(gizmoSize));
|
||||
const gizmoAxisColorsRef = useRef(resolveGizmoAxisColors(gizmoAxisColors));
|
||||
const selectedNodeNameRef = useRef<string | null>(selectedNodeName);
|
||||
@ -1008,6 +857,11 @@ function ModelViewerComponent({
|
||||
gizmoModeRef.current = gizmoMode;
|
||||
}, [gizmoMode]);
|
||||
|
||||
useEffect(() => {
|
||||
coordinateSystemRef.current = coordinateSystem;
|
||||
syncTransformControlsRef.current?.();
|
||||
}, [coordinateSystem]);
|
||||
|
||||
useEffect(() => {
|
||||
gizmoSizeRef.current = resolveGizmoSize(gizmoSize);
|
||||
transformControlsRef.current?.setSize(gizmoSizeRef.current);
|
||||
@ -1214,7 +1068,9 @@ function ModelViewerComponent({
|
||||
}
|
||||
|
||||
object.updateMatrixWorld(true);
|
||||
const currentWorldPosition = object.getWorldPosition(new THREE.Vector3());
|
||||
const currentWorldPosition = coordinateSystemRef.current === "z-up"
|
||||
? getObjectWorldBoundsCenter(object)
|
||||
: object.getWorldPosition(new THREE.Vector3());
|
||||
const currentWorldQuaternion = object.getWorldQuaternion(
|
||||
new THREE.Quaternion(),
|
||||
);
|
||||
@ -1231,14 +1087,12 @@ function ModelViewerComponent({
|
||||
transformControlsRef.current as TransformControls & { axis?: string | null }
|
||||
)?.axis;
|
||||
const activeAxisSet = getTransformFeedbackActiveAxes(activeAxis);
|
||||
const worldAxes = getCoordinateWorldAxes(coordinateSystemRef.current);
|
||||
|
||||
if (baseline.mode === "translate") {
|
||||
const delta = currentWorldPosition.clone().sub(baseline.worldPosition);
|
||||
const valueByAxis: Record<AxisKey, number> = {
|
||||
X: delta.x,
|
||||
Y: delta.y,
|
||||
Z: delta.z,
|
||||
};
|
||||
const [x, y, z] = fromWorldVector3(delta.toArray(), coordinateSystemRef.current);
|
||||
const valueByAxis: Record<AxisKey, number> = { X: x, Y: y, Z: z };
|
||||
|
||||
return TRANSFORM_FEEDBACK_AXIS_KEYS.flatMap((axis) => {
|
||||
const value = valueByAxis[axis];
|
||||
@ -1251,8 +1105,8 @@ function ModelViewerComponent({
|
||||
|
||||
const direction =
|
||||
value < 0
|
||||
? TRANSFORM_FEEDBACK_WORLD_AXIS[axis].clone().multiplyScalar(-1)
|
||||
: TRANSFORM_FEEDBACK_WORLD_AXIS[axis].clone();
|
||||
? worldAxes[axis].clone().multiplyScalar(-1)
|
||||
: worldAxes[axis].clone();
|
||||
const screenPoint = projectWorldPointToContainer(
|
||||
currentWorldPosition
|
||||
.clone()
|
||||
@ -1284,15 +1138,15 @@ function ModelViewerComponent({
|
||||
const degreeByAxis: Record<AxisKey, number> = {
|
||||
X: getSignedWorldAxisRotationDegrees(
|
||||
worldDeltaQuaternion,
|
||||
TRANSFORM_FEEDBACK_WORLD_AXIS.X,
|
||||
worldAxes.X,
|
||||
),
|
||||
Y: getSignedWorldAxisRotationDegrees(
|
||||
worldDeltaQuaternion,
|
||||
TRANSFORM_FEEDBACK_WORLD_AXIS.Y,
|
||||
worldAxes.Y,
|
||||
),
|
||||
Z: getSignedWorldAxisRotationDegrees(
|
||||
worldDeltaQuaternion,
|
||||
TRANSFORM_FEEDBACK_WORLD_AXIS.Z,
|
||||
worldAxes.Z,
|
||||
),
|
||||
};
|
||||
|
||||
@ -1307,8 +1161,8 @@ function ModelViewerComponent({
|
||||
|
||||
const direction =
|
||||
value < 0
|
||||
? TRANSFORM_FEEDBACK_WORLD_AXIS[axis].clone().multiplyScalar(-1)
|
||||
: TRANSFORM_FEEDBACK_WORLD_AXIS[axis].clone();
|
||||
? worldAxes[axis].clone().multiplyScalar(-1)
|
||||
: worldAxes[axis].clone();
|
||||
const screenPoint = projectWorldPointToContainer(
|
||||
currentWorldPosition
|
||||
.clone()
|
||||
@ -1898,7 +1752,9 @@ function ModelViewerComponent({
|
||||
transformFeedbackBaselineRef.current = {
|
||||
nodeName: activeNodeName,
|
||||
mode: gizmoModeRef.current,
|
||||
worldPosition: activeTargetState.baselineAnchorPosition.clone(),
|
||||
worldPosition: coordinateSystemRef.current === "z-up"
|
||||
? getObjectWorldBoundsCenter(object)
|
||||
: activeTargetState.baselineAnchorPosition.clone(),
|
||||
worldQuaternion: object.getWorldQuaternion(new THREE.Quaternion()),
|
||||
};
|
||||
setTransformFeedbackLabels([]);
|
||||
@ -1934,6 +1790,9 @@ function ModelViewerComponent({
|
||||
} else {
|
||||
setTransformFeedbackLabels([]);
|
||||
}
|
||||
if (coordinateSystemRef.current === "z-up") {
|
||||
syncTransformControlsRef.current?.();
|
||||
}
|
||||
scheduleTransformFeedbackHide();
|
||||
};
|
||||
const handleMouseDown = () => {
|
||||
@ -2182,6 +2041,11 @@ function ModelViewerComponent({
|
||||
targetObjects.length > 0 ? targetObjects.map((item) => item.object) : [targetObject],
|
||||
activeNodeName,
|
||||
);
|
||||
configureGizmoCoordinateFrame(
|
||||
currentTransformControls,
|
||||
transformAnchor,
|
||||
coordinateSystemRef.current,
|
||||
);
|
||||
}
|
||||
const isSamePrimaryTarget =
|
||||
previousTargetState?.anchor === transformAnchor &&
|
||||
@ -2930,6 +2794,7 @@ export const ModelViewer = memo(
|
||||
prevProps.installationPerformance === nextProps.installationPerformance &&
|
||||
prevProps.enableTranslateGizmo === nextProps.enableTranslateGizmo &&
|
||||
prevProps.gizmoMode === nextProps.gizmoMode &&
|
||||
prevProps.coordinateSystem === nextProps.coordinateSystem &&
|
||||
prevProps.gizmoSize === nextProps.gizmoSize &&
|
||||
areStringRecordsEqual(
|
||||
prevProps.gizmoAxisColors,
|
||||
|
||||
@ -31,7 +31,7 @@ const resolveDefaultTransformTargetMock = jest.fn(() => ({
|
||||
}));
|
||||
const sceneAddMock = jest.fn();
|
||||
const sceneRemoveMock = jest.fn();
|
||||
const orbitControls = { enabled: true };
|
||||
const orbitControls = { enabled: true, update: jest.fn() };
|
||||
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 5000);
|
||||
camera.position.set(0, 0, 1000);
|
||||
camera.lookAt(0, 0, 0);
|
||||
@ -103,6 +103,7 @@ jest.mock("three/examples/jsm/controls/TransformControls.js", () => {
|
||||
jest.mock("@/lib/3d/engine", () => ({
|
||||
ThreeDEngine: jest.fn().mockImplementation(() => ({
|
||||
loadModel: loadModelMock,
|
||||
setRepairDisplayNormals: jest.fn(),
|
||||
getNamedNodeNames: getNamedNodeNamesMock,
|
||||
setHiddenNodes: setHiddenNodesMock,
|
||||
getModelRoot: getModelRootMock,
|
||||
@ -264,6 +265,88 @@ describe("ModelViewer gizmo mode switching", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('switches only the gizmo frame when the coordinate convention changes', async () => {
|
||||
const view = render(<ModelViewer modelUrl="/demo.glb" enableTranslateGizmo selectedNodeName="panel-a" />);
|
||||
await flushViewer();
|
||||
const controls = transformControlsInstances[0]!;
|
||||
expect(controls.setSpace).toHaveBeenLastCalledWith('world');
|
||||
view.rerender(<ModelViewer modelUrl="/demo.glb" enableTranslateGizmo selectedNodeName="panel-a" coordinateSystem="z-up" />);
|
||||
await flushViewer();
|
||||
expect(transformControlsInstances).toHaveLength(1);
|
||||
expect(controls.setSpace).toHaveBeenLastCalledWith('local');
|
||||
view.rerender(<ModelViewer modelUrl="/demo.glb" enableTranslateGizmo selectedNodeName="panel-a" coordinateSystem="y-up" />);
|
||||
await flushViewer();
|
||||
expect(controls.setSpace).toHaveBeenLastCalledWith('world');
|
||||
expect(targetObject.position.toArray()).toEqual([0, 0, 0]);
|
||||
expect(targetObject.quaternion.toArray()).toEqual([0, 0, 0, 1]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["X", [200, 0, 0]],
|
||||
["Y", [0, 0, -200]],
|
||||
["Z", [0, 200, 0]],
|
||||
] as const)("Z-up %s drag uses the matching furniture axis and preserves stored pose", async (axis, delta) => {
|
||||
targetObject.traverse(object => {
|
||||
if (object instanceof THREE.Mesh) object.geometry.translate(40, -30, -20);
|
||||
});
|
||||
targetObject.position.set(10, 20, 30);
|
||||
targetObject.rotation.set(0.3, 0.7, 0.2);
|
||||
targetObject.updateMatrixWorld(true);
|
||||
const originalRotation = targetObject.quaternion.clone();
|
||||
getNodeLocalRotationMock.mockReturnValue(originalRotation.toArray());
|
||||
const onOffset = jest.fn();
|
||||
getNodeWorldOffsetMock.mockImplementation(() => targetObject.position.toArray());
|
||||
render(<ModelViewer modelUrl="/demo.glb" coordinateSystem="z-up" enableTranslateGizmo selectedNodeName="panel-a" onNodePositionOffsetChange={onOffset} />);
|
||||
await flushViewer();
|
||||
const controls = transformControlsInstances[0]!;
|
||||
expect(controls.setSpace).toHaveBeenLastCalledWith("local");
|
||||
expect(targetObject.position.toArray()).toEqual([10, 20, 30]);
|
||||
expect(targetObject.quaternion.angleTo(originalRotation)).toBeCloseTo(0);
|
||||
const basis = new THREE.Vector3(...({ X: [1, 0, 0], Y: [0, 1, 0], Z: [0, 0, 1] }[axis] as [number, number, number]));
|
||||
expect(basis.applyQuaternion(controls.object!.quaternion).distanceTo(new THREE.Vector3(...delta).normalize())).toBeLessThan(1e-12);
|
||||
act(() => {
|
||||
controls.axis = axis;
|
||||
controls.dispatchEvent({ type: "mouseDown" });
|
||||
controls.object!.position.add(new THREE.Vector3(...delta));
|
||||
controls.object!.updateMatrixWorld(true);
|
||||
controls.dispatchEvent({ type: "objectChange" });
|
||||
});
|
||||
expect(onOffset).toHaveBeenCalledWith({ nodeName: "panel-a", offset: delta.map((value, index) => value + [10, 20, 30][index]!) });
|
||||
expect(targetObject.quaternion.angleTo(originalRotation)).toBeCloseTo(0);
|
||||
await waitFor(() => expect(screen.getByText(`${axis} +200mm`)).toBeInTheDocument());
|
||||
for (const other of ["X", "Y", "Z"].filter(other => other !== axis)) expect(screen.queryByText(new RegExp(`${other} `))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Y", [0, 0, -1]],
|
||||
["Z", [0, 1, 0]],
|
||||
] as const)("Z-up %s rotation reports its furniture axis and resets only the gizmo frame", async (axis, worldAxis) => {
|
||||
targetObject.rotation.set(0.3, 0.7, 0.2);
|
||||
targetObject.updateMatrixWorld(true);
|
||||
const originalRotation = targetObject.quaternion.clone();
|
||||
getNodeLocalRotationMock.mockReturnValue(originalRotation.toArray());
|
||||
const selectable = ['panel-a'];
|
||||
const rotations = [{ nodeName: 'panel-a', rotation: originalRotation.toArray() }];
|
||||
render(<ModelViewer modelUrl="/demo.glb" coordinateSystem="z-up" enableTranslateGizmo selectedNodeName="panel-a" gizmoMode="rotate" selectableNodeNames={selectable} nodeRotationStates={rotations} />);
|
||||
await flushViewer();
|
||||
const controls = transformControlsInstances[0]!;
|
||||
const frame = controls.object!.quaternion.clone();
|
||||
const delta = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(...worldAxis), Math.PI / 12);
|
||||
act(() => {
|
||||
controls.axis = axis;
|
||||
controls.dispatchEvent({ type: "mouseDown" });
|
||||
controls.object!.quaternion.copy(delta.clone().multiply(frame));
|
||||
controls.object!.updateMatrixWorld(true);
|
||||
controls.dispatchEvent({ type: "objectChange" });
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText(`${axis} +15°`)).toBeInTheDocument());
|
||||
const expected = delta.clone().multiply(originalRotation);
|
||||
expect(targetObject.quaternion.angleTo(expected)).toBeCloseTo(0);
|
||||
act(() => controls.dispatchEvent({ type: "mouseUp" }));
|
||||
expect(controls.object!.quaternion.angleTo(frame)).toBeCloseTo(0);
|
||||
expect(targetObject.quaternion.angleTo(expected)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("allows callers to enlarge the transform gizmo for compact viewports", async () => {
|
||||
const view = render(
|
||||
<ModelViewer
|
||||
|
||||
@ -0,0 +1,163 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
export interface TransformGizmoTargetState {
|
||||
nodeName: string | null;
|
||||
nodeNames: string[];
|
||||
object: THREE.Object3D;
|
||||
targetObjects: Array<{
|
||||
nodeName: string;
|
||||
object: THREE.Object3D;
|
||||
baselineTargetMatrixWorld: THREE.Matrix4 | null;
|
||||
}>;
|
||||
anchor: THREE.Object3D;
|
||||
isFallbackModelTarget: boolean;
|
||||
baselineAnchorPosition: THREE.Vector3 | null;
|
||||
baselineAnchorQuaternion: THREE.Quaternion | null;
|
||||
baselineTargetMatrixWorld: THREE.Matrix4 | null;
|
||||
}
|
||||
|
||||
export const MODEL_VIEWER_GIZMO_ANCHOR_NAME = "__model_viewer_gizmo_anchor__";
|
||||
|
||||
export function getObjectWorldBoundsCenter(object: THREE.Object3D): THREE.Vector3 {
|
||||
object.updateMatrixWorld(true);
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
if (!box.isEmpty()) {
|
||||
return box.getCenter(new THREE.Vector3());
|
||||
}
|
||||
return object.getWorldPosition(new THREE.Vector3());
|
||||
}
|
||||
|
||||
function getObjectsWorldBoundsCenter(objects: THREE.Object3D[]): THREE.Vector3 {
|
||||
if (objects.length === 0) {
|
||||
return new THREE.Vector3();
|
||||
}
|
||||
|
||||
const box = new THREE.Box3();
|
||||
let hasBounds = false;
|
||||
objects.forEach((object) => {
|
||||
object.updateMatrixWorld(true);
|
||||
const objectBox = new THREE.Box3().setFromObject(object);
|
||||
if (objectBox.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!hasBounds) {
|
||||
box.copy(objectBox);
|
||||
hasBounds = true;
|
||||
return;
|
||||
}
|
||||
box.union(objectBox);
|
||||
});
|
||||
|
||||
if (hasBounds) {
|
||||
return box.getCenter(new THREE.Vector3());
|
||||
}
|
||||
|
||||
return objects[0]?.getWorldPosition(new THREE.Vector3()) ?? new THREE.Vector3();
|
||||
}
|
||||
|
||||
function syncGizmoAnchorToObjectCenter(
|
||||
anchor: THREE.Object3D,
|
||||
object: THREE.Object3D,
|
||||
nodeName: string | null,
|
||||
) {
|
||||
anchor.name = nodeName
|
||||
? `${nodeName}${MODEL_VIEWER_GIZMO_ANCHOR_NAME}`
|
||||
: MODEL_VIEWER_GIZMO_ANCHOR_NAME;
|
||||
anchor.position.copy(getObjectWorldBoundsCenter(object));
|
||||
anchor.quaternion.copy(object.getWorldQuaternion(new THREE.Quaternion()));
|
||||
anchor.scale.set(1, 1, 1);
|
||||
anchor.updateMatrixWorld(true);
|
||||
}
|
||||
|
||||
export function syncGizmoAnchorToSelectionCenter(
|
||||
anchor: THREE.Object3D,
|
||||
objects: THREE.Object3D[],
|
||||
nodeName: string | null,
|
||||
) {
|
||||
const primaryObject = objects[0];
|
||||
if (!primaryObject) {
|
||||
syncGizmoAnchorToObjectCenter(anchor, new THREE.Object3D(), nodeName);
|
||||
return;
|
||||
}
|
||||
|
||||
anchor.name = nodeName
|
||||
? `${nodeName}${MODEL_VIEWER_GIZMO_ANCHOR_NAME}`
|
||||
: MODEL_VIEWER_GIZMO_ANCHOR_NAME;
|
||||
anchor.position.copy(getObjectsWorldBoundsCenter(objects));
|
||||
anchor.quaternion.copy(primaryObject.getWorldQuaternion(new THREE.Quaternion()));
|
||||
anchor.scale.set(1, 1, 1);
|
||||
anchor.updateMatrixWorld(true);
|
||||
}
|
||||
|
||||
export function applyGizmoAnchorTransformToTarget(
|
||||
targetState: TransformGizmoTargetState,
|
||||
mode: "translate" | "rotate",
|
||||
) {
|
||||
const {
|
||||
anchor,
|
||||
baselineAnchorPosition,
|
||||
baselineAnchorQuaternion,
|
||||
} = targetState;
|
||||
if (
|
||||
!baselineAnchorPosition ||
|
||||
!baselineAnchorQuaternion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
anchor.updateMatrixWorld(true);
|
||||
const targetObjects =
|
||||
targetState.targetObjects.length > 0
|
||||
? targetState.targetObjects
|
||||
: targetState.nodeName && targetState.baselineTargetMatrixWorld
|
||||
? [
|
||||
{
|
||||
nodeName: targetState.nodeName,
|
||||
object: targetState.object,
|
||||
baselineTargetMatrixWorld: targetState.baselineTargetMatrixWorld,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
targetObjects.forEach((targetObjectState) => {
|
||||
const { object, baselineTargetMatrixWorld } = targetObjectState;
|
||||
if (!baselineTargetMatrixWorld) {
|
||||
return;
|
||||
}
|
||||
|
||||
object.parent?.updateMatrixWorld(true);
|
||||
const nextWorldMatrix = new THREE.Matrix4();
|
||||
|
||||
if (mode === "rotate") {
|
||||
const currentAnchorQuaternion = anchor.getWorldQuaternion(
|
||||
new THREE.Quaternion(),
|
||||
);
|
||||
const deltaQuaternion = currentAnchorQuaternion
|
||||
.clone()
|
||||
.multiply(baselineAnchorQuaternion.clone().invert())
|
||||
.normalize();
|
||||
const pivot = baselineAnchorPosition.clone();
|
||||
const rotateAroundPivotMatrix = new THREE.Matrix4()
|
||||
.makeTranslation(pivot.x, pivot.y, pivot.z)
|
||||
.multiply(new THREE.Matrix4().makeRotationFromQuaternion(deltaQuaternion))
|
||||
.multiply(new THREE.Matrix4().makeTranslation(-pivot.x, -pivot.y, -pivot.z));
|
||||
nextWorldMatrix
|
||||
.copy(rotateAroundPivotMatrix)
|
||||
.multiply(baselineTargetMatrixWorld);
|
||||
} else {
|
||||
const currentAnchorPosition = anchor.getWorldPosition(new THREE.Vector3());
|
||||
const deltaPosition = currentAnchorPosition.sub(baselineAnchorPosition);
|
||||
nextWorldMatrix
|
||||
.makeTranslation(deltaPosition.x, deltaPosition.y, deltaPosition.z)
|
||||
.multiply(baselineTargetMatrixWorld);
|
||||
}
|
||||
|
||||
const parentWorldInverse = object.parent
|
||||
? object.parent.matrixWorld.clone().invert()
|
||||
: new THREE.Matrix4();
|
||||
const nextLocalMatrix = parentWorldInverse.multiply(nextWorldMatrix);
|
||||
nextLocalMatrix.decompose(object.position, object.quaternion, object.scale);
|
||||
object.updateMatrix();
|
||||
object.updateMatrixWorld(true);
|
||||
});
|
||||
}
|
||||
@ -14,6 +14,7 @@ import {
|
||||
} from '@/lib/3d/engine'
|
||||
import { CameraController } from '@/lib/3d/camera-controller'
|
||||
import { ModelViewer, type ModelViewerWorldNode } from '@/components/3d/ModelViewer'
|
||||
import type { ViewportCoordinateSystem } from '@/lib/3d/coordinate-system'
|
||||
import type { HolePanelBounds } from '@/components/3d/HoleOverlay2D'
|
||||
import type { HoleData as HoleOverlayData } from '@/components/3d/HoleViewer'
|
||||
import { readPreviewWorldNodes } from '@/lib/open-product/platform-view'
|
||||
@ -55,6 +56,7 @@ interface ModelPreviewPanelProps {
|
||||
gizmoMode?: 'translate' | 'rotate'
|
||||
gizmoSize?: number
|
||||
gizmoAxisColors?: Partial<Record<'X' | 'Y' | 'Z', string>>
|
||||
coordinateSystem?: ViewportCoordinateSystem
|
||||
onGizmoModeChange?: (mode: 'translate' | 'rotate') => void
|
||||
selectedNodeName?: string | null
|
||||
selectedNodeNames?: string[]
|
||||
@ -158,6 +160,7 @@ export function ModelPreviewPanel({
|
||||
gizmoMode = 'translate',
|
||||
gizmoSize,
|
||||
gizmoAxisColors,
|
||||
coordinateSystem = 'y-up',
|
||||
onGizmoModeChange,
|
||||
selectedNodeName = null,
|
||||
selectedNodeNames = [],
|
||||
@ -419,6 +422,7 @@ export function ModelPreviewPanel({
|
||||
gizmoMode={gizmoMode}
|
||||
gizmoSize={gizmoSize}
|
||||
gizmoAxisColors={gizmoAxisColors}
|
||||
coordinateSystem={coordinateSystem}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@ -37,6 +37,13 @@ describe('ModelPreviewPanel', () => {
|
||||
mockModelViewer.mockClear()
|
||||
})
|
||||
|
||||
it('uses Y-up by default and forwards the opt-in furniture frame', () => {
|
||||
const view = render(<ModelPreviewPanel modelUrl="/demo.glb" />)
|
||||
expect(mockModelViewer).toHaveBeenLastCalledWith(expect.objectContaining({ coordinateSystem: 'y-up' }))
|
||||
view.rerender(<ModelPreviewPanel modelUrl="/demo.glb" coordinateSystem="z-up" />)
|
||||
expect(mockModelViewer).toHaveBeenLastCalledWith(expect.objectContaining({ coordinateSystem: 'z-up' }))
|
||||
})
|
||||
|
||||
it('keeps the top-right actions visible in empty state', () => {
|
||||
render(
|
||||
<ModelPreviewPanel
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import * as THREE from 'three'
|
||||
import { configureGizmoCoordinateFrame, fromWorldVector3, getCoordinateWorldAxes, toWorldVector3 } from '../coordinate-system'
|
||||
|
||||
describe('furniture Z-up coordinates over existing Y-up storage', () => {
|
||||
it('maps right, back and up into a right-handed world frame', () => {
|
||||
expect(toWorldVector3([200, 0, 0], 'z-up')).toEqual([200, 0, 0])
|
||||
expect(toWorldVector3([0, 200, 0], 'z-up')).toEqual([0, 0, -200])
|
||||
expect(toWorldVector3([0, 0, 200], 'z-up')).toEqual([0, 200, 0])
|
||||
const axes = getCoordinateWorldAxes('z-up')
|
||||
expect(axes.X.clone().cross(axes.Y).dot(axes.Z)).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('round-trips signed offsets without mutating stored positions', () => {
|
||||
const saved: [number, number, number] = [-12.5, 200, -46.75]
|
||||
expect(toWorldVector3(fromWorldVector3(saved, 'z-up'), 'z-up')).toEqual(saved)
|
||||
expect(saved).toEqual([-12.5, 200, -46.75])
|
||||
expect(toWorldVector3(saved, 'y-up')).toEqual(saved)
|
||||
expect(fromWorldVector3(saved, 'y-up')).toEqual(saved)
|
||||
})
|
||||
|
||||
it('keeps the gizmo in furniture axes regardless of the selected model rotation', () => {
|
||||
const anchor = new THREE.Object3D()
|
||||
anchor.rotation.set(0.3, 0.7, 0.2)
|
||||
anchor.position.set(11, 22, 33)
|
||||
const controls = { setSpace: jest.fn() }
|
||||
configureGizmoCoordinateFrame(controls, anchor, 'z-up')
|
||||
expect(controls.setSpace).toHaveBeenLastCalledWith('local')
|
||||
const axes = getCoordinateWorldAxes('z-up')
|
||||
for (const [axis, vector] of Object.entries(getCoordinateWorldAxes('y-up'))) {
|
||||
expect(vector.applyQuaternion(anchor.quaternion).distanceTo(axes[axis as keyof typeof axes])).toBeLessThan(1e-12)
|
||||
}
|
||||
expect(anchor.position.toArray()).toEqual([11, 22, 33])
|
||||
const previousQuaternion = anchor.quaternion.clone()
|
||||
configureGizmoCoordinateFrame(controls, anchor, 'y-up')
|
||||
expect(controls.setSpace).toHaveBeenLastCalledWith('world')
|
||||
expect(anchor.quaternion.equals(previousQuaternion)).toBe(true)
|
||||
})
|
||||
})
|
||||
41
furniture-3d-system/src/lib/3d/coordinate-system.ts
Normal file
41
furniture-3d-system/src/lib/3d/coordinate-system.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import * as THREE from 'three'
|
||||
import type { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'
|
||||
|
||||
export type ViewportCoordinateSystem = 'y-up' | 'z-up'
|
||||
export type CoordinateVector3 = [number, number, number]
|
||||
|
||||
/** Z-up furniture coordinates: +X right, +Y back, +Z up. Storage stays Y-up. */
|
||||
export function toWorldVector3(
|
||||
[x, y, z]: readonly [number, number, number],
|
||||
system: ViewportCoordinateSystem,
|
||||
): CoordinateVector3 {
|
||||
return system === 'z-up' ? [x, z, y === 0 ? 0 : -y] : [x, y, z]
|
||||
}
|
||||
|
||||
export function fromWorldVector3(
|
||||
[x, y, z]: readonly [number, number, number],
|
||||
system: ViewportCoordinateSystem,
|
||||
): CoordinateVector3 {
|
||||
return system === 'z-up' ? [x, z === 0 ? 0 : -z, y] : [x, y, z]
|
||||
}
|
||||
|
||||
export function getCoordinateWorldAxes(system: ViewportCoordinateSystem) {
|
||||
return {
|
||||
X: new THREE.Vector3(...toWorldVector3([1, 0, 0], system)),
|
||||
Y: new THREE.Vector3(...toWorldVector3([0, 1, 0], system)),
|
||||
Z: new THREE.Vector3(...toWorldVector3([0, 0, 1], system)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Orient only the detached gizmo anchor; never rotate the model or saved poses. */
|
||||
export function configureGizmoCoordinateFrame(
|
||||
controls: Pick<TransformControls, 'setSpace'>,
|
||||
anchor: THREE.Object3D,
|
||||
system: ViewportCoordinateSystem,
|
||||
) {
|
||||
controls.setSpace(system === 'z-up' ? 'local' : 'world')
|
||||
if (system === 'z-up') {
|
||||
anchor.quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2)
|
||||
anchor.updateMatrixWorld(true)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user