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:
WuYongJie 2026-10-19 17:54:47 +08:00
parent cba68a8cc7
commit 09f36608de
10 changed files with 390 additions and 186 deletions

View File

@ -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: '精确移动部件' }))

View File

@ -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>
)

View File

@ -615,6 +615,7 @@ export function InstallationPreviewStage({
preserveDrawingBuffer
repairDisplayNormals
installationPerformance={installationPerformance}
coordinateSystem="z-up"
showFpsBadge={false}
className="h-full"
viewportControlsEnabled={

View File

@ -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,

View File

@ -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

View File

@ -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);
});
}

View File

@ -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>

View File

@ -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

View File

@ -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)
})
})

View 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)
}
}